diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c768e8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 동봉한 LibreDWG 실행 파일 — 줄바꿈 변환이 닿으면 실행이 깨진다. +B07_DesignDetail/openwebcad/tools/libredwg/*.exe binary +B07_DesignDetail/openwebcad/tools/libredwg/*.dll binary diff --git a/.gitignore b/.gitignore index 0095f399..e1a96c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,9 @@ resources/data_global_contours/national_contours.gpkg .tmp_* *.log.err tmp/ -**/.obsidian/ \ No newline at end of file +**/.obsidian/ +# 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04). +# 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py). +config/corridor_node/ +# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06). +config/server_calc_node/ 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..9c05c761 --- /dev/null +++ b/A00_Common/b_page_state.ts @@ -0,0 +1,470 @@ +/* ============================================================================= + * 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}` }, + /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). + * 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야 + * 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */ + cutslope: { bucket: "draft", scope: "route" }, + /** 소단 제원(측점키 → {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_structures_section.ts b/A00_Common/b_structures_section.ts index 051169fc..653431d8 100644 --- a/A00_Common/b_structures_section.ts +++ b/A00_Common/b_structures_section.ts @@ -7,6 +7,9 @@ * 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다. * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "../B05_Profile/B05_Profile_UI_Style_Table.css"; +import "../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../B05_Profile/B05_Profile_UI_Style_Structures.css"; export { diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index 8f58d4bb..52a2b613 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -14,6 +14,8 @@ import { navigateTo } from "./router"; export interface WorkflowState { project_id: string; + /** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */ + project_name?: string | null; current_stage: number; stages: WorkflowStage[]; } @@ -28,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; } /** @@ -50,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/A06_Login/A06_Login_UI_Auth_Page.ts b/A06_Login/A06_Login_UI_Auth_Page.ts index 2b5f9e9a..45e92a9a 100644 --- a/A06_Login/A06_Login_UI_Auth_Page.ts +++ b/A06_Login/A06_Login_UI_Auth_Page.ts @@ -14,6 +14,56 @@ import "./A06_Login_UI_Style.css"; const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; +/* 인증 코드 입력 단계는 **새로고침을 견뎌야 한다**(2026-09-04 사용자 보고). + * 휴대폰에서 코드를 보러 메일 앱으로 갔다 오면 브라우저가 탭을 버려 페이지가 다시 뜨는데, + * 그때 단계가 메모리에만 있어 이메일/비밀번호 화면으로 되돌아갔다. 서버는 코드를 DB에 + * 5분간 들고 있고 검증에 이메일+코드만 쓰므로(`/api/auth/login/verify`), 그 사이 단계만 + * 세션에 남겨 두면 받은 코드를 그대로 넣을 수 있다. 비밀번호는 저장하지 않는다. */ +const OTP_PENDING_KEY = "a06:otp-pending"; +/** 서버 OTP 유효시간(분) — `config_system.OTP_VALID_MINUTES` 기본값과 맞춘다. */ +const OTP_VALID_MS = 5 * 60 * 1000; +/** 재발송 대기(초) — 아래 쿨다운과 같은 값. */ +const RESEND_COOLDOWN_S = 60; + +interface OtpPending { + email: string; + /** 코드를 보낸 시각(ms). 유효시간·재발송 대기 계산의 기준. */ + sentAt: number; +} + +function readOtpPending(): OtpPending | null { + try { + const raw = sessionStorage.getItem(OTP_PENDING_KEY); + if (!raw) return null; + const value = JSON.parse(raw) as OtpPending; + if (!value?.email || typeof value.sentAt !== "number") return null; + // 유효시간이 지났으면 되살리지 않는다 — 어차피 서버가 거절한다. + if (Date.now() - value.sentAt > OTP_VALID_MS) { + sessionStorage.removeItem(OTP_PENDING_KEY); + return null; + } + return value; + } catch { + return null; + } +} + +function writeOtpPending(email: string): void { + try { + sessionStorage.setItem(OTP_PENDING_KEY, JSON.stringify({ email, sentAt: Date.now() })); + } catch { + // 세션 저장이 막힌 브라우저(시크릿 등)에서는 예전처럼 메모리로만 동작한다. + } +} + +function clearOtpPending(): void { + try { + sessionStorage.removeItem(OTP_PENDING_KEY); + } catch { + // 지우기 실패는 무시 — 유효시간이 지나면 어차피 되살리지 않는다. + } +} + export function renderA06Login(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a06-login"; @@ -59,9 +109,10 @@ export function renderA06Login(root: HTMLElement): void { setButtonLabel(resend, L("A06_Login_OtpResend")); } - function startResendCooldown(): void { + function startResendCooldown(remainSeconds = RESEND_COOLDOWN_S): void { stopResendCooldown(); - resendSeconds = 60; + if (remainSeconds <= 0) return; + resendSeconds = remainSeconds; resend.disabled = true; const updateLabel = (): void => { setButtonLabel( @@ -80,17 +131,18 @@ export function renderA06Login(root: HTMLElement): void { }, 1000); } - function showOtpStep(): void { + function showOtpStep(remainSeconds = RESEND_COOLDOWN_S): void { otpRequired = true; email.input.disabled = true; otp.root.hidden = false; otpActions.hidden = false; password.root.hidden = true; setButtonLabel(submit, L("A06_Login_Verify")); - startResendCooldown(); + startResendCooldown(remainSeconds); } function onA06_Login_Back_Click(): void { + clearOtpPending(); otpRequired = false; email.input.disabled = false; password.root.hidden = false; @@ -107,6 +159,7 @@ export function renderA06Login(root: HTMLElement): void { try { const result = await requestLogin(email.input.value.trim(), password.input.value); if (result.status === "otp_required") { + writeOtpPending(email.input.value.trim()); showToast(L("A06_Login_OtpSent"), "info"); startResendCooldown(); } else { @@ -140,9 +193,11 @@ export function renderA06Login(root: HTMLElement): void { ? await verifyLogin(emailValue, otp.input.value) : await requestLogin(emailValue, password.input.value); if (result.status === "otp_required") { + writeOtpPending(emailValue); showOtpStep(); showToast(L("A06_Login_OtpSent"), "info"); } else { + clearOtpPending(); showToast(L("A06_Login_Success"), "success"); navigateTo(ROUTES.B01_ACCOUNT); } @@ -163,4 +218,14 @@ export function renderA06Login(root: HTMLElement): void { card.append(title, form, register); page.append(card); root.append(page); + + // 코드를 기다리던 중 화면이 다시 뜬 경우 — 그 단계로 되돌려 준다(이메일은 채워 두고, + // 남은 재발송 대기도 보낸 시각 기준으로 이어 센다). + const pending = readOtpPending(); + if (pending) { + email.input.value = pending.email; + const elapsed = Math.floor((Date.now() - pending.sentAt) / 1000); + showOtpStep(Math.max(RESEND_COOLDOWN_S - elapsed, 0)); + otp.input.focus(); + } } diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 1d55c657..e258ca87 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -29,6 +29,8 @@ export interface WorkflowStageState { export interface WorkflowState { project_id: string; + /** 좌측 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). */ + project_name?: string | null; current_stage: number; stages: WorkflowStageState[]; } @@ -41,6 +43,9 @@ export interface ProjectItem { road_type?: string | null; project_year?: number | null; estimated_length_m?: number | null; + /** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */ + route_start_m?: number | null; + route_end_m?: number | null; memo?: string | null; status?: string | null; /** 도면 표제란·표지에 실리는 값 — 프로그램이 지어낼 수 없어 사람이 넣는다 */ @@ -54,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; } @@ -68,6 +74,7 @@ export interface CompanyInfo { business_address?: string | null; business_owner?: string | null; business_status?: string | null; + logo_asset_id?: number | null; user_count?: number; project_count?: number; } @@ -110,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; } @@ -151,6 +161,9 @@ export interface UpdateProjectRequest { road_type?: string | null; project_year?: number | null; estimated_length_m?: number | null; + /** 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. */ + route_start_m?: number | null; + route_end_m?: number | null; memo?: string | null; status?: string | null; client_org?: string | null; @@ -162,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 { @@ -232,7 +246,9 @@ export async function searchCompanies(query: string): Promise { return data.companies; } -export function createCompany(payload: CreateCompanyRequest): Promise { +export function createCompany( + payload: CreateCompanyRequest, +): Promise<{ company_id: number; status: string }> { return request("/dashboard/user/company/create", { method: "POST", body: body(payload) }); } @@ -277,6 +293,17 @@ export async function createCompanyAsset(form: FormData): Promise { return data.asset_id; } +/** 자산의 이름·주인을 고친다. 서명을 사람 계정에 물릴 때 쓴다 (2026-09-02). */ +export function updateCompanyAsset( + assetId: number, + payload: { label: string; user_id: number | null }, +): Promise { + return request(`/dashboard/company/assets/${assetId}`, { + method: "PUT", + body: body(payload), + }); +} + export function deleteCompanyAsset(assetId: number): Promise { return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" }); } @@ -284,8 +311,65 @@ export function deleteCompanyAsset(assetId: number): Promise { export const companyAssetFileUrl = (assetId: number): string => `${API_BASE_URL}/dashboard/company/assets/${assetId}/file`; -export function addCompanyMember(email: string): Promise { - return request("/dashboard/admin/members", { method: "POST", body: body({ email }) }); +/** 주소를 좌표로 바꾼다 (회사 주소 지도 미리보기). */ +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({ 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, + companyId?: number | null, +): Promise { + return request(`/dashboard/company/logo${companyQuery(companyId)}`, { + method: "PUT", + body: body({ logo_asset_id: logoAssetId }), + }); } export function removeCompanyMember(userId: number): Promise { @@ -380,8 +464,11 @@ export async function fetchSystemResources(days = 30): Promise { return request(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`); } -export function fetchProjectWorkflowState(projectId: string): Promise { - return request(`/projects/${projectId}/workflow-state`, { - method: "GET", - }) as Promise; +export async function fetchProjectWorkflowState(projectId: string): Promise { + // 응답은 `{status, workflow_state}` 껍데기로 온다 — 벗겨서 상태만 넘긴다. + const data = await request<{ workflow_state?: WorkflowState } & WorkflowState>( + `/projects/${projectId}/workflow-state`, + { method: "GET" }, + ); + return data.workflow_state ?? data; } 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 b78a2d69..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 @@ -158,7 +193,8 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT id, company_id, name, region, road_type, project_year, - estimated_length_m, memo, status, updated_at, created_at, + estimated_length_m, route_start_m, route_end_m, + memo, status, updated_at, created_at, client_org, project_number, work_amount, design_date, pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id, signature_asset_id @@ -174,7 +210,8 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, - p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, + p.estimated_length_m, p.route_start_m, p.route_end_m, + p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, p.pm_user_id, p.field_lead_user_id, p.designer_user_id, p.logo_asset_id, p.signature_asset_id, @@ -192,7 +229,8 @@ async def list_all_projects() -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year, - p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at, + p.estimated_length_m, p.route_start_m, p.route_end_m, + p.memo, p.status, p.updated_at, p.created_at, p.client_org, p.project_number, p.work_amount, p.design_date, p.pm_user_id, p.field_lead_user_id, p.designer_user_id, p.logo_asset_id, p.signature_asset_id, @@ -209,7 +247,7 @@ async def get_project(project_id: str) -> dict[str, Any] | None: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT id, user_id, company_id, name, region, road_type, project_year, - estimated_length_m, memo, status, + estimated_length_m, route_start_m, route_end_m, memo, status, client_org, project_number, work_amount, design_date, pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id, signature_asset_id @@ -219,14 +257,17 @@ 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() await cursor.execute( """UPDATE projects SET name = %s, region = %s, road_type = %s, project_year = %s, - estimated_length_m = %s, memo = %s, status = COALESCE(%s, status), + estimated_length_m = %s, route_start_m = %s, route_end_m = %s, + memo = %s, status = COALESCE(%s, status), client_org = %s, project_number = %s, work_amount = %s, design_date = %s, pm_user_id = %s, field_lead_user_id = %s, designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s @@ -237,6 +278,8 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - data.get("road_type"), data.get("project_year"), data.get("estimated_length_m"), + data.get("route_start_m"), + data.get("route_end_m"), data.get("memo"), data.get("status"), data.get("client_org"), @@ -252,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() @@ -273,263 +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, - 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_company_members(company_id: int) -> list[dict[str, Any]]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT id, email, name, position, department, role, is_master, status - FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""", - (company_id,), - ) - rows = list(await cursor.fetchall()) - for row in rows: - row["role"] = _role(row.get("role")) - row["is_master"] = bool(row.get("is_master")) - return rows - - -async def add_company_member(company_id: int, email: str) -> 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(),), - ) - user = await cursor.fetchone() - if not user or user["company_id"] == company_id: - await connection.rollback() - return None - await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', - is_master = FALSE - WHERE id = %s""", - (company_id, user["id"]), - ) - await connection.commit() - return await get_dashboard_me(user["id"]) - - -async def remove_company_member(company_id: int, user_id: int) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER', - is_master = FALSE - WHERE id = %s AND company_id = %s AND is_master = FALSE""", - (user_id, company_id), - ) - changed = cursor.rowcount > 0 - await connection.commit() - return changed - - -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.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: @@ -549,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 @@ -604,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() @@ -624,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), @@ -688,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 new file mode 100644 index 00000000..14a57d54 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -0,0 +1,179 @@ +"""회사 구성원과 회사 대표 로고 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한). + +구성원은 도면 표제란의 사람 자리(과업책임자·분야별책임자·설계자)를 채우는 원천이라 +자산·로고와 같은 결로 묶어 둔다. +""" + +from __future__ import annotations + +from typing import Any + +import aiomysql +from fastapi import HTTPException + +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company +from .B01_Dashboard_Repository_Assets import list_company_assets + + +async def list_company_members(company_id: int) -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, email, name, position, department, role, is_master, status + FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""", + (company_id,), + ) + rows = list(await cursor.fetchall()) + for row in rows: + row["role"] = _role(row.get("role")) + row["is_master"] = bool(row.get("is_master")) + return rows + + +async def list_unassigned_users(query: str) -> list[dict[str, Any]]: + """소속이 없는 가입자만 찾는다 (2026-09-06 사용자 확정). + + 다른 회사 소속자는 보이지 않는다 — 팀원 등록은 「이미 가입한 사람을 고르는」 일이다. + """ + 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 id = %s AND deleted_at IS NULL FOR UPDATE""", + (user_id,), + ) + user = await cursor.fetchone() + 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 = %s, + is_master = FALSE + WHERE id = %s""", + (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) + + +async def remove_company_member(company_id: int, user_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER', + is_master = FALSE + WHERE id = %s AND company_id = %s AND is_master = FALSE""", + (user_id, company_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def set_company_logo(company_id: int, asset_id: int | None) -> bool: + """회사 대표 로고를 지정한다 (2026-09-02 사용자 확정 — 회사 등록 단계에서 받고 변경). + + `asset_id` 가 같은 회사의 `kind='LOGO'` 자산인지는 라우터가 확인한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "UPDATE companies SET logo_asset_id = %s WHERE id = %s AND deleted_at IS NULL", + (asset_id, company_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: + """담당자·로고·서명은 그 회사의 것만 물린다 (2026-09-02 사용자 확정). + + 프로젝트 수정(B01)과 등록(B02)이 같은 규칙을 써야 해서 저장소에 둔다. + """ + 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 = { + k: kind + for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE")) + if data.get(k) + } + if wanted: + 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 46ad1b6d..0635b8dc 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -1,39 +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_system_admin, verify_session +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 ( - add_company_member, 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_members, list_company_projects, - list_join_requests, list_user_projects, - process_join_request, - remove_company_member, - search_companies, + save_user_ui_prefs, soft_delete_project, + soft_delete_user, update_admin_user, update_project, update_user_profile, @@ -49,15 +58,38 @@ 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 ( + 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, AdminUpdateUserRequest, AssignCompanyRequest, ChangeUserRoleRequest, CreateCompanyRequest, + InviteMemberRequest, JoinCompanyRequest, ProcessJoinRequest, + UiPrefsRequest, UpdateCompanyAssetRequest, + UpdateCompanyLogoRequest, UpdateProjectRequest, UpdateUserRequest, ) @@ -102,31 +134,15 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An return asset -async def _check_project_refs(project: dict[str, Any], data: dict[str, Any]) -> None: - """담당자·로고·서명은 프로젝트 회사의 것만 물린다 (2026-09-02 사용자 확정).""" - company_id = int(project["company_id"]) - user_ids = { - data[k] for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data[k] - } - 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 = { - k: kind - for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE")) - if data[k] - } - if wanted: - 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="로고·서명은 같은 회사 자산이어야 합니다.") - - -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: @@ -156,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") @@ -177,27 +218,84 @@ 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} @router.get("/admin/members") async def admin_members( company_id: int | None = Query(None, gt=0), - session: dict[str, Any] = Depends(require_company_admin), + session: dict[str, Any] = Depends(require_company), ): + # 읽기는 회사 구성원 누구나 — B02 등록 화면에서 일반 사용자도 담당자를 골라야 한다 + # (2026-09-02 사용자 지시). 쓰기(POST·DELETE)는 관리자 그대로다. # company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다. return { "status": "success", @@ -205,17 +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) + 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) @@ -259,44 +392,53 @@ 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() - await _check_project_refs(project, data) - if not await update_project(project_id, data, int(session["user_id"])): + # 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시). + start_m, end_m = data.get("route_start_m"), data.get("route_end_m") + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, + detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", + ) + await check_project_refs(int(project["company_id"]), data) + 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"} @@ -309,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} @@ -326,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="사용자를 찾을 수 없습니다.") @@ -359,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, @@ -475,6 +660,26 @@ async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends( return {"status": "success"} +@router.put("/company/logo") +async def company_set_logo( + payload: UpdateCompanyLogoRequest, + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): + """회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정). + + 프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다. + """ + scoped = _scope_company(session, company_id) + if payload.logo_asset_id is not None: + asset = await get_company_asset(payload.logo_asset_id) + if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO": + raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.") + if not await set_company_logo(scoped, payload.logo_asset_id): + raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") + return {"status": "success"} + + @router.get("/company/assets/{asset_id}/file") async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)): """목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다.""" diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index d5d40b6f..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,7 +34,21 @@ 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) + name: str | None = Field(default=None, max_length=100) + + +class UpdateCompanyLogoRequest(BaseModel): + # 회사 대표 로고 (company_assets.id, kind=LOGO). None 이면 지정을 지운다. + logo_asset_id: int | None = Field(default=None, gt=0) class ProcessJoinRequest(BaseModel): @@ -42,10 +66,16 @@ 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) estimated_length_m: float | None = Field(default=None, ge=0) + # 계획노선 사용 범위 (2026-09-04 사용자 지시) — 비우면 전 구간. + route_start_m: float | None = Field(default=None, ge=0) + route_end_m: float | None = Field(default=None, ge=0) memo: str | None = Field(default=None, max_length=5000) status: str | None = Field(default=None, max_length=50) # 도면 표제란·표지에 실리는 값 (2026-09-02). 프로그램이 지어낼 수 없어 사람이 넣는다. 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 2f43dd9d..780028be 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -14,12 +14,27 @@ import { type CompanyAsset, type DashboardUser, } from "./B01_Dashboard_Api_Fetch"; +import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; export interface AssetFieldHandle { root: HTMLDivElement; value: () => number | null; } +export interface AssetFieldOptions { + /** 이 자산의 주인을 사람으로 못박는다 — 사용자 서명 칸(2026-09-02 사용자 확정). */ + owner?: { id: number; name: string } | null; + /** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */ + onChange?: (assetId: number | null) => void | Promise; + /** + * 비었을 때 대신 보여 줄 기본값 — 프로젝트 로고를 안 고르면 도면에는 회사 로고가 + * 실린다(`COALESCE(p.logo_asset_id, c.logo_asset_id)`). 그 사실을 화면에도 보인다 + * (2026-09-04 사용자 지시). **저장값은 계속 null** — 회사 로고를 바꾸면 따라가야 하므로 + * 값을 복사해 굳히지 않는다. + */ + fallback?: { asset: CompanyAsset | null; prefix: string; missingNote: string }; +} + const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const; /** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */ @@ -30,8 +45,14 @@ export function createAssetField( initialId: number | null | undefined, companyId: number | null | undefined, user: DashboardUser, + options: AssetFieldOptions = {}, ): AssetFieldHandle { - let list = assets.filter((asset) => asset.kind === kind); + const owner = options.owner ?? null; + // 주인이 못박힌 칸은 그 사람 것(또는 아직 주인 없는 것)만 보인다. + let list = assets.filter( + (asset) => + asset.kind === kind && (!owner || asset.user_id === owner.id || asset.user_id == null), + ); let selected = list.find((asset) => asset.id === initialId) ?? null; const root = document.createElement("div"); @@ -47,10 +68,29 @@ export function createAssetField( const name = document.createElement("span"); name.className = "b01-dashboard__asset-name"; + const fallback = options.fallback ?? null; + const reset = createButton({ + label: "기본으로", + variant: "ghost", + onClick: () => { + selected = null; + render(); + void options.onChange?.(null); + }, + }); + const render = (): void => { - preview.hidden = !selected; - if (selected) preview.src = companyAssetFileUrl(selected.id); - name.textContent = selected ? selected.label : "(없음)"; + const shown = selected ?? fallback?.asset ?? null; + preview.hidden = !shown; + if (shown) preview.src = companyAssetFileUrl(shown.id); + if (selected) name.textContent = selected.label; + else if (fallback) { + name.textContent = fallback.asset + ? `${fallback.prefix} · ${fallback.asset.label}` + : fallback.missingNote; + } else name.textContent = "(없음)"; + // 프로젝트 전용 값을 골랐을 때만 기본 연결로 되돌릴 거리가 생긴다. + reset.hidden = !fallback || selected === null; }; render(); @@ -58,17 +98,125 @@ export function createAssetField( label: "선택…", variant: "ghost", onClick: () => - openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => { - list = fresh; - selected = next; - render(); - }), + openAssetPickerModal( + kind, + list, + selected?.id ?? null, + companyId, + user, + (next, fresh) => { + list = fresh; + selected = next; + render(); + void options.onChange?.(next?.id ?? null); + }, + owner, + ), }); row.append(preview, name, pick); + if (fallback) row.append(reset); root.append(caption, row); return { root, value: () => selected?.id ?? null }; } +/** + * 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다. + * 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다. + */ +function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise } { + const root = document.createElement("div"); + root.className = "ui-field"; + const caption = document.createElement("label"); + caption.className = "ui-field__label"; + caption.textContent = "또는 마우스로 그리기"; + const canvas = document.createElement("canvas"); + canvas.width = 480; + canvas.height = 160; + canvas.className = "b01-dashboard__sign-pad"; + const context = canvas.getContext("2d"); + let drawn = false; + let drawing = false; + + if (context) { + context.lineWidth = 2.5; + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = "#111111"; + } + // 캔버스 좌표는 CSS 크기가 아니라 픽셀 크기 기준이라 비율로 환산한다. + const at = (event: PointerEvent): [number, number] => { + const box = canvas.getBoundingClientRect(); + return [ + ((event.clientX - box.left) * canvas.width) / box.width, + ((event.clientY - box.top) * canvas.height) / box.height, + ]; + }; + canvas.addEventListener("pointerdown", (event) => { + if (!context) return; + drawing = true; + drawn = true; + canvas.setPointerCapture(event.pointerId); + const [x, y] = at(event); + context.beginPath(); + context.moveTo(x, y); + }); + canvas.addEventListener("pointermove", (event) => { + if (!drawing || !context) return; + const [x, y] = at(event); + context.lineTo(x, y); + context.stroke(); + }); + const stop = (): void => { + drawing = false; + }; + canvas.addEventListener("pointerup", stop); + canvas.addEventListener("pointercancel", stop); + + const clear = createButton({ + label: "지우기", + variant: "ghost", + onClick: () => { + context?.clearRect(0, 0, canvas.width, canvas.height); + drawn = false; + }, + }); + root.append(caption, canvas, clear); + + return { + root, + toFile: () => + new Promise((resolve) => { + if (!drawn) return resolve(null); + canvas.toBlob( + (blob) => resolve(blob ? new File([blob], "signature.png", { type: "image/png" }) : null), + "image/png", + ); + }), + }; +} + +/** + * 자산 고르기 모달을 버튼 하나로 연다 — 표 안(회사 목록)처럼 칸을 둘 자리가 없을 때. + * 자산 목록은 열 때 받는다(회사가 여럿이면 미리 다 받을 이유가 없다). + */ +export async function openAssetPicker( + kind: CompanyAsset["kind"], + companyId: number, + user: DashboardUser, + currentId: number | null, + onPick: (assetId: number | null) => void | Promise, +): Promise { + const assets = await fetchCompanyAssets(companyId); + openAssetPickerModal( + kind, + assets.filter((asset) => asset.kind === kind), + currentId, + companyId, + user, + (next) => void onPick(next?.id ?? null), + ); +} + function openAssetPickerModal( kind: CompanyAsset["kind"], assets: CompanyAsset[], @@ -76,6 +224,7 @@ function openAssetPickerModal( companyId: number | null | undefined, user: DashboardUser, onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void, + owner: { id: number; name: string } | null = null, ): void { let list = assets; const modal = document.createElement("div"); @@ -88,6 +237,7 @@ function openAssetPickerModal( const grid = document.createElement("div"); grid.className = "b01-dashboard__asset-grid"; + let dismiss: ModalDismissHandle | null = null; const close = (): void => modal.remove(); const choose = (asset: CompanyAsset | null): void => { onPick(asset, list); @@ -150,19 +300,32 @@ function openAssetPickerModal( mine.className = "b01-dashboard__check"; const mineBox = document.createElement("input"); mineBox.type = "checkbox"; - mineBox.checked = kind === "SIGNATURE"; - mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`)); + mineBox.checked = owner !== null || kind === "SIGNATURE"; + // 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다. + mineBox.disabled = owner !== null; + // 무슨 뜻인지 읽히게 고침 (2026-09-06 사용자 지시) — 체크를 풀면 회사 공용이 된다. + mine.append( + mineBox, + document.createTextNode( + ` 이 그림을 ${owner ? owner.name : `내 계정(${user.name})`}의 것으로 지정 (풀면 회사 공용)`, + ), + ); + const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ - label: "올리고 선택", + label: "파일 올리고 이 프로젝트에 쓰기", onClick: async () => { - const chosen = file.input.files?.[0]; if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요."); - if (!chosen) return file.setError("그림 파일을 고르세요."); + const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; + if (!chosen) { + return file.setError( + pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.", + ); + } const form = new FormData(); form.append("kind", kind); form.append("label", label.input.value.trim()); form.append("file", chosen); - if (mineBox.checked) form.append("user_id", String(user.id)); + if (mineBox.checked) form.append("user_id", String(owner ? owner.id : user.id)); if (companyId) form.append("company_id", String(companyId)); try { const id = await createCompanyAsset(form); @@ -177,8 +340,14 @@ function openAssetPickerModal( const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; - actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close })); - panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions); + actions.append( + createButton({ label: "닫기", variant: "ghost", onClick: () => void dismiss?.tryClose() }), + ); + panel.append(heading, grid, addTitle, label.root, file.root); + if (pad) panel.append(pad.root); + panel.append(mine, add, actions); modal.append(panel); document.body.append(modal); + // 「신규 추가」에 이름·파일을 넣어 두고 바깥을 누르면 한 번 묻는다 (2026-09-04 사용자 지시). + dismiss = attachModalDismiss(modal, panel); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 90aba864..b7ba551d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -1,5 +1,11 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; +import { + createInputField, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; /** @@ -33,3 +39,201 @@ export async function runRequest(action: () => Promise): Promise 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 사용자 지시) + * + * 껍데기를 만드는 자리가 세 곳(Modals·AssetPicker·TempModal)이라 규칙을 여기 한 곳에 + * 둔다. 고친 게 있으면 공용 `showConfirmDialog` 로 한 번 묻고, 없으면 바로 닫는다. + * -------------------------------------------------------------------------- */ + +/** 모달 안 입력값을 한 줄로 떠 둔다 — 열 때와 닫을 때를 견주어 변경을 판정한다. */ +export function snapshotModalFields(panel: HTMLElement): string { + const parts: string[] = []; + for (const node of panel.querySelectorAll("input, select, textarea")) { + if (node instanceof HTMLInputElement && node.type === "checkbox") { + parts.push(node.checked ? "1" : "0"); + } else if (node instanceof HTMLInputElement && node.type === "file") { + parts.push( + Array.from(node.files ?? []) + .map((file) => `${file.name}:${file.size}`) + .join(","), + ); + } else { + parts.push((node as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).value); + } + } + return parts.join("\u0001"); +} + +export interface ModalDismissOptions { + /** 입력칸 밖에서 바뀌는 값(고른 로고·고른 파일 목록)을 덧붙인다. */ + extra?: () => string; + /** 실제로 닫는 동작 — 기본은 모달 제거. */ + close?: () => void; +} + +export interface ModalDismissHandle { + isDirty: () => boolean; + /** 변경이 있으면 확인창을 거쳐 닫는다 — [취소] 단추도 이것을 쓴다. */ + tryClose: () => Promise; +} + +export function attachModalDismiss( + modal: HTMLElement, + panel: HTMLElement, + options: ModalDismissOptions = {}, +): ModalDismissHandle { + const extraOf = (): string => options.extra?.() ?? ""; + let baseline = snapshotModalFields(panel); + const extraBaseline = extraOf(); + // 값을 나중에 채우는 칸(비동기 조회)이 있어, 사용자가 아직 손대기 전이면 기준을 다시 뜬다. + let touched = false; + const mark = (): void => { + touched = true; + }; + panel.addEventListener("input", mark, true); + panel.addEventListener("change", mark, true); + + const isDirty = (): boolean => { + if (extraOf() !== extraBaseline) return true; + const now = snapshotModalFields(panel); + if (!touched) { + baseline = now; + return false; + } + return now !== baseline; + }; + + const onKey = (event: KeyboardEvent): void => { + if (!modal.isConnected) { + document.removeEventListener("keydown", onKey, true); + return; + } + if (event.key !== "Escape") return; + // 확인창이 떠 있으면 그쪽이 먼저고, 모달이 여럿이면 맨 위 것만 닫는다. + if (document.querySelector(".ui-confirm")) return; + const opened = document.querySelectorAll(".b01-dashboard__modal"); + if (opened[opened.length - 1] !== modal) return; + event.stopPropagation(); + void tryClose(); + }; + + const close = (): void => { + document.removeEventListener("keydown", onKey, true); + if (options.close) options.close(); + else modal.remove(); + }; + + const tryClose = async (): Promise => { + if (!isDirty()) return close(); + const ok = await showConfirmDialog("변경한 내용이 저장되지 않습니다. 닫을까요?", "닫기"); + if (ok) close(); + }; + + // 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다. + let downOnOverlay = false; + modal.addEventListener("mousedown", (event) => { + downOnOverlay = event.target === modal; + }); + modal.addEventListener("click", (event) => { + if (event.target === modal && downOnOverlay) void tryClose(); + }); + document.addEventListener("keydown", onKey, true); + + 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 99509b9d..b6a7af21 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -1,14 +1,18 @@ import { createButton, createTag } from "@ui/ui_template_elements"; import { + fetchCompanyAssets, processJoinRequest, + setCompanyLogo, type CompanyInfo, type DashboardUser, type JoinRequest, type Member, } from "./B01_Dashboard_Api_Fetch"; +import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker"; import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; import { openChangeRoleModal, + openEditCompanyModal, openCreateCompanyModal, openDeleteUserModal, openEditUserModal, @@ -41,6 +45,37 @@ 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") { + const company = state.company; + const slot = document.createElement("div"); + void fetchCompanyAssets(company.id).then((assets) => { + slot.append( + createAssetField( + "회사 로고 (도면 표제란)", + "LOGO", + assets, + company.logo_asset_id ?? null, + company.id, + state.user, + { onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) }, + ).root, + ); + }); + wrap.append(slot); + } return wrap; } @@ -122,17 +157,48 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): ); } -export function companyTable(companies: CompanyInfo[]): HTMLElement { +export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement { + // 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정). + const logoCell = (company: CompanyInfo): HTMLElement => { + if (!user) return text(""); + const button = createButton({ + label: company.logo_asset_id ? "로고 변경…" : "로고 지정…", + variant: "ghost", + onClick: () => + void openAssetPicker( + "LOGO", + company.id, + user, + company.logo_asset_id ?? null, + async (id) => { + await setCompanyLogo(id, company.id); + company.logo_asset_id = id; + button.textContent = id ? "로고 변경…" : "로고 지정…"; + }, + ), + }); + return button; + }; return table( [ L("B01_Dashboard_Table_Company"), 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 9d114aae..9af03c60 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -14,22 +14,42 @@ 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 { 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]; } -function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise): void { +function openModal( + title: string, + body: HTMLElement[], + onConfirm: () => Promise, + options: { extra?: () => string } = {}, +): void { const modal = document.createElement("div"); modal.className = "b01-dashboard__modal"; const panel = document.createElement("div"); @@ -39,11 +59,13 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise< heading.textContent = title; const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; + // 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다. + let dismiss: ModalDismissHandle | null = null; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", - onClick: () => modal.remove(), + onClick: () => void dismiss?.tryClose(), }), createButton({ label: L("Common_Btn_Confirm"), @@ -67,18 +89,28 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise< panel.append(heading, ...body, actions); modal.append(panel); document.body.append(modal); + dismiss = attachModalDismiss(modal, panel, { extra: options.extra }); } 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 사용자 확정). - const [members, assets] = await Promise.all([ + // 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시). + const [members, assets, company] = await Promise.all([ fetchCompanyMembers(project.company_id), fetchCompanyAssets(project.company_id), + fetchUserCompany().catch(() => null), ]); + // 남의 회사 프로젝트(시스템관리자)에서는 내 회사 로고를 기본으로 내밀지 않는다. + const sameCompany = company != null && company.id === project.company_id; + const companyLogo = + (sameCompany && + assets.find((asset) => asset.kind === "LOGO" && asset.id === company.logo_asset_id)) || + null; const name = createInputField({ label: L("B01_Dashboard_Table_Project"), @@ -100,6 +132,19 @@ export async function openEditProjectModal( type: "number", value: String(project.estimated_length_m ?? ""), }); + // 계획노선 사용 범위 — 등록(B02)에서 받은 값을 여기서도 고친다 (2026-09-04 사용자 지시). + const routeStart = createInputField({ + label: "노선 시작 누가거리 (m)", + type: "number", + value: project.route_start_m == null ? "" : String(project.route_start_m), + placeholder: "비우면 처음부터", + }); + const routeEnd = createInputField({ + label: "노선 종료 누가거리 (m)", + type: "number", + value: project.route_end_m == null ? "" : String(project.route_end_m), + placeholder: "비우면 끝까지", + }); const memo = createInputField({ label: "비고", value: project.memo ?? "" }); // 도면 표제란·표지에 그대로 실리는 값 — 프로그램이 지어낼 수 없어 여기서 받는다. const clientOrg = createInputField({ @@ -122,15 +167,24 @@ export async function openEditProjectModal( type: "date", value: (project.design_date ?? "").slice(0, 10), }); + const memberText = (member: Member) => + member.position ? `${member.name} (${member.position})` : member.name; const personOptions = [ { value: "", text: "(미지정)" }, - ...members.map((member) => ({ - value: String(member.id), - text: member.position ? `${member.name} (${member.position})` : member.name, - })), + ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), ]; - const person = (label: string, current: number | null | undefined) => - createSelectField({ label, options: personOptions, value: String(current ?? "") }); + const persons: HTMLSelectElement[] = []; + // 담당자는 이미 등록된 팀원 중에서만 고른다 (2026-09-06 사용자 확정) — + // 이 자리에서 계정을 만드는 「신규 등록…」은 없앴다. + const person = (label: string, current: number | null | undefined) => { + const field = createSelectField({ + label, + options: personOptions, + value: String(current ?? ""), + }); + persons.push(field.select); + return field; + }; const pm = person("과업책임자 (도면 표제란)", project.pm_user_id); const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id); const designer = person("설계자 (도면 표제란)", project.designer_user_id); @@ -141,22 +195,25 @@ export async function openEditProjectModal( project.logo_asset_id, project.company_id, user, + { + // 프로젝트가 안 고르면 도면에는 회사 로고가 실린다 — 저장값은 계속 비워 둬 연결을 유지한다. + fallback: sameCompany + ? { + asset: companyLogo, + prefix: "회사 기본 로고", + missingNote: "(없음) — 회사 로고 미지정 (회사 정보 화면의 「로고 지정…」)", + } + : undefined, + }, ); - const signature = createAssetField( - "설계자 서명 (도면 표제란)", - "SIGNATURE", - assets, - project.signature_asset_id, - project.company_id, - user, - ); - if (isUserOnly) { name.input.disabled = true; region.input.disabled = true; roadType.input.disabled = true; year.input.disabled = true; length.input.disabled = true; + routeStart.input.disabled = true; + routeEnd.input.disabled = true; memo.input.disabled = true; clientOrg.input.disabled = true; projectNumber.input.disabled = true; @@ -176,6 +233,8 @@ export async function openEditProjectModal( roadType.root, year.root, length.root, + routeStart.root, + routeEnd.root, memo.root, clientOrg.root, projectNumber.root, @@ -185,31 +244,63 @@ export async function openEditProjectModal( fieldLead.root, designer.root, logo.root, - signature.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); - openModal(L("B01_Dashboard_EditProject"), [grid], async () => { - await updateProject(project.id, { - name: name.input.value.trim(), - region: region.input.value.trim() || null, - road_type: roadType.input.value.trim() || null, - project_year: year.input.value ? Number(year.input.value) : null, - estimated_length_m: length.input.value ? Number(length.input.value) : null, - memo: memo.input.value.trim() || null, - status: project.status, - client_org: clientOrg.input.value.trim() || null, - project_number: projectNumber.input.value.trim() || null, - work_amount: workAmount.input.value.trim() || null, - design_date: designDate.input.value || null, - pm_user_id: userId(pm.select), - field_lead_user_id: userId(fieldLead.select), - designer_user_id: userId(designer.select), - logo_asset_id: logo.value(), - signature_asset_id: signature.value(), - }); - showToast(L("B01_Dashboard_Saved"), "success"); - }); + // 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다. + const editProjectExtra = (): string => String(logo.value() ?? ""); + + openModal( + L("B01_Dashboard_EditProject"), + [grid, memberBox], + async () => { + await updateProject(project.id, { + name: name.input.value.trim(), + region: region.input.value.trim() || null, + road_type: roadType.input.value.trim() || null, + project_year: year.input.value ? Number(year.input.value) : null, + estimated_length_m: length.input.value ? Number(length.input.value) : null, + route_start_m: routeStart.input.value ? Number(routeStart.input.value) : null, + route_end_m: routeEnd.input.value ? Number(routeEnd.input.value) : null, + memo: memo.input.value.trim() || null, + status: project.status, + client_org: clientOrg.input.value.trim() || null, + project_number: projectNumber.input.value.trim() || null, + work_amount: workAmount.input.value.trim() || null, + design_date: designDate.input.value || null, + pm_user_id: userId(pm.select), + field_lead_user_id: userId(fieldLead.select), + designer_user_id: userId(designer.select), + 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"); + }, + { extra: editProjectExtra }, + ); } export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void { @@ -227,48 +318,57 @@ 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 department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: target.department ?? "", + 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 phoneVal = (target as DashboardUser).phone || ""; - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); + // 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를 + // 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다. + const signatureSlot = document.createElement("div"); + const companyId = (target as DashboardUser).company_id ?? user.company_id; + 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 }); + }, + }).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], - 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 { @@ -288,41 +388,172 @@ 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") }); + 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], + [...fields.rows, findBtn, matches], async () => { - if (!name.input.value.trim() || !number.input.value.trim()) return; - 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, - }); + 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", `${values.name} 로고`); + form.append("file", file); + form.append("company_id", String(created.company_id)); + const assetId = await createCompanyAsset(form); + await setCompanyLogo(assetId, created.company_id); + } showToast(L("B01_Dashboard_Saved"), "success"); }, ); } +/** 회사 정보 수정 (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"); @@ -361,15 +592,81 @@ export function openFindCompanyModal(): void { ); } -export function openAddMemberModal(): void { - const email = createInputField({ - label: L("B01_Dashboard_Field_MemberEmail"), - type: "email", - required: true, +/** + * 팀원 등록 — 이미 가입한 사람 중 **소속이 없는 사람**만 골라 붙인다 + * (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는 + * 안내 메일만 보낸다. + */ +export function openAddMemberModal(onCreated?: (member: Member) => void): void { + const query = createInputField({ + label: "이름 또는 이메일로 찾기", + placeholder: "두 글자 이상", }); - openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => { - if (!email.input.value.trim()) return; - await addCompanyMember(email.input.value.trim()); + 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 215d7ba4..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"; @@ -148,10 +148,9 @@ function buildPage(state: DashboardState): HTMLElement { createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), - section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true, [ + 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 cd70421d..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); @@ -241,6 +258,18 @@ object-fit: contain; } +/* 서명 그리기 판 — touch-action 을 끊어야 끌기가 스크롤로 새지 않는다 */ +.b01-dashboard__sign-pad { + width: 100%; + height: auto; + aspect-ratio: 3 / 1; + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + cursor: crosshair; + touch-action: none; +} + .b01-dashboard__check { display: block; margin: var(--spacing-8) 0 var(--spacing-16); @@ -261,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/B01_Dashboard/B01_Dashboard_UI_TempModal.ts b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts index e5a55b41..a4e7a305 100644 --- a/B01_Dashboard/B01_Dashboard_UI_TempModal.ts +++ b/B01_Dashboard/B01_Dashboard_UI_TempModal.ts @@ -12,7 +12,7 @@ import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { table, text } from "@ui/ui_template_general_blocks"; -import { L } from "./B01_Dashboard_UI_Common"; +import { attachModalDismiss, L, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; /** 파일 확장자 = 보관함 슬롯 종류(csv·shp 세트·las·prj·tfw·tif). */ export function tempFileType(fileName: string): string { @@ -113,11 +113,13 @@ export function openTempFileModal(options: TempModalOptions): void { const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; + // 닫는 길은 하나로 — [취소]·바깥 클릭·Esc 모두 같은 변경 확인을 거친다 (2026-09-04 사용자 지시). + let dismiss: ModalDismissHandle | null = null; actions.append( createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", - onClick: () => modal.remove(), + onClick: () => void dismiss?.tryClose(), }), createButton({ label: L("Common_Btn_Confirm"), @@ -143,4 +145,8 @@ export function openTempFileModal(options: TempModalOptions): void { panel.append(pickRow, listHost, picker, actions); modal.append(panel); document.body.append(modal); + // 고른 파일은 입력칸이 아니라 목록에 쌓이므로 따로 견준다. + dismiss = attachModalDismiss(modal, panel, { + extra: () => chosen.map((file) => `${file.name}:${file.size}`).join(","), + }); } diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index da7989e9..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, @@ -51,10 +53,12 @@ async def create_project( project_year: int, estimated_length_m: float | None, memo: str | None, + title_block: dict[str, Any] | None = None, ) -> dict[str, Any]: """신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다.""" project_id = str(uuid4()) + fields = title_block or {} storage_path, project_root = _build_project_storage(company_id, user_id, project_id) now = datetime.utcnow() @@ -66,10 +70,14 @@ async def create_project( """ INSERT INTO projects ( id, user_id, company_id, name, region, road_type, - project_year, estimated_length_m, memo, status, - crs_epsg, storage_path, created_at, updated_at + project_year, estimated_length_m, route_start_m, route_end_m, + memo, status, + crs_epsg, storage_path, created_at, updated_at, + client_org, project_number, work_amount, design_date, + pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s) """, ( project_id, @@ -80,19 +88,37 @@ async def create_project( road_type, project_year, estimated_length_m, + fields.get("route_start_m"), + fields.get("route_end_m"), memo, storage_path, now, now, + fields.get("client_org"), + fields.get("project_number"), + fields.get("work_amount"), + fields.get("design_date"), + fields.get("pm_user_id"), + fields.get("field_lead_user_id"), + fields.get("designer_user_id"), + 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() @@ -103,7 +129,8 @@ async def create_project( await cursor.execute( """ SELECT id AS project_id, name, region, road_type, project_year, - estimated_length_m, memo, status, storage_path, + estimated_length_m, route_start_m, route_end_m, + memo, status, storage_path, DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at FROM projects WHERE id = %s @@ -125,7 +152,8 @@ async def get_project_by_id(project_id: str) -> dict[str, Any] | None: await cursor.execute( """ SELECT id AS project_id, user_id, company_id, name, region, road_type, - project_year, estimated_length_m, memo, status, storage_path, + project_year, estimated_length_m, route_start_m, route_end_m, + memo, status, storage_path, DATE_FORMAT(created_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS created_at FROM projects WHERE id = %s AND deleted_at IS NULL diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f053b985..54934c5f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -2,8 +2,9 @@ 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 from .B02_ProjRegister_Repository import create_project @@ -14,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: @@ -23,8 +25,33 @@ async def post_project( if company_id is None: raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + title_block = payload.model_dump( + include={ + "client_org", + "project_number", + "work_amount", + "design_date", + "pm_user_id", + "field_lead_user_id", + "designer_user_id", + "logo_asset_id", + "route_start_m", + "route_end_m", + } + ) + # 시작이 종료보다 뒤면 남는 구간이 없다 — 저장 전에 막는다 (2026-09-04 사용자 지시). + start_m, end_m = payload.route_start_m, payload.route_end_m + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, + detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", + ) + # 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙). + await check_project_refs(int(company_id), title_block) + try: result = await create_project( + request=request, user_id=int(session["user_id"]), company_id=int(company_id), name=payload.name.strip(), @@ -33,6 +60,7 @@ async def post_project( project_year=payload.project_year, estimated_length_m=payload.estimated_length_m, memo=payload.memo.strip() if payload.memo else None, + title_block=title_block, ) return CreateProjectResponse(**result) except ValueError as exc: diff --git a/B02_ProjRegister/B02_ProjRegister_Schema.py b/B02_ProjRegister/B02_ProjRegister_Schema.py index 6b21c748..55b6a047 100644 --- a/B02_ProjRegister/B02_ProjRegister_Schema.py +++ b/B02_ProjRegister/B02_ProjRegister_Schema.py @@ -1,5 +1,7 @@ """B02_ProjRegister 요청/응답 스키마.""" +from datetime import date + from pydantic import BaseModel, Field @@ -8,10 +10,26 @@ class CreateProjectRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) region: str = Field(..., min_length=1, max_length=100) - road_type: str = Field(..., pattern="^(main|branch|fire|stream)$") + # 화면 선택지와 같은 3종 — 간선·산불진화·작업임도 (지선임도 폐지, 계류보전은 사방사업). + # 옛 값(branch·stream)을 받고 work 를 막고 있어 「작업임도」 등록이 422 로 떨어졌다. + road_type: str = Field(..., pattern="^(main|fire|work)$") project_year: int = Field(..., ge=2000, le=2100) estimated_length_m: float | None = Field(default=None, ge=0) + # 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). + # 둘 다 비우면 전 구간. 시작 >= 종료 는 라우터에서 막는다. + route_start_m: float | None = Field(default=None, ge=0) + route_end_m: float | None = Field(default=None, ge=0) memo: str | None = Field(default=None, max_length=1000) + # 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시). + # 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다. + client_org: str | None = Field(default=None, max_length=255) + project_number: str | None = Field(default=None, max_length=100) + work_amount: str | None = Field(default=None, max_length=100) + design_date: date | None = None + pm_user_id: int | None = Field(default=None, gt=0) + field_lead_user_id: int | None = Field(default=None, gt=0) + designer_user_id: int | None = Field(default=None, gt=0) + logo_asset_id: int | None = Field(default=None, gt=0) class CreateProjectResponse(BaseModel): @@ -23,6 +41,8 @@ class CreateProjectResponse(BaseModel): road_type: str | None project_year: int | None estimated_length_m: float | None + route_start_m: float | None = None + route_end_m: float | None = None memo: str | None status: str storage_path: str 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 7aa195ae..68faf689 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -19,7 +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 { 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"; @@ -33,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({ @@ -76,9 +69,17 @@ 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"), + // 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). + // 둘 다 비우면 전 구간을 쓴다. + const routeStartField = createInputField({ + label: "노선 시작 누가거리 (m)", + placeholder: "비우면 처음부터", + type: "number", + min: 0, + }); + const routeEndField = createInputField({ + label: "노선 종료 누가거리 (m)", + placeholder: "비우면 끝까지", type: "number", min: 0, }); @@ -88,25 +89,111 @@ export function renderB02ProjRegister(root: HTMLElement): void { type: "text", }); + // 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다 + // (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다. + const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" }); + // 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과 + // 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다. + const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" }); + + 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]; + // 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어 + // 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`). + + void (async () => { + 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) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = text; + select.append(option); + }; + for (const select of personSelects) { + for (const member of members) addOption(select, String(member.id), memberText(member)); + // 담당자 기본값은 만든 사람 (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 ? 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; @@ -119,8 +206,23 @@ 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")); + const routeStart = isBlank(routeStartField.input.value) + ? null + : Number.parseFloat(routeStartField.input.value); + const routeEnd = isBlank(routeEndField.input.value) + ? null + : Number.parseFloat(routeEndField.input.value); + if (routeStart !== null && (!Number.isFinite(routeStart) || routeStart < 0)) { + routeStartField.setError(L("Common_Validation_NumberRange")); + hasError = true; + } + if (routeEnd !== null && (!Number.isFinite(routeEnd) || routeEnd < 0)) { + routeEndField.setError(L("Common_Validation_NumberRange")); + hasError = true; + } + // 시작이 종료보다 뒤면 남는 구간이 없다 — 서버도 같은 규칙으로 막는다. + if (routeStart !== null && routeEnd !== null && routeStart >= routeEnd) { + routeEndField.setError("종료 누가거리는 시작보다 커야 합니다."); hasError = true; } if (hasError) return; @@ -132,12 +234,23 @@ 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, estimated_length_m: estimatedLength, + route_start_m: routeStart, + route_end_m: routeEnd, memo: memoField.input.value.trim() || null, + client_org: clientOrgField.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), }), }); @@ -167,15 +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, + clientOrgField.root, + designDateField.root, + pmField.root, + fieldLeadField.root, + designerField.root, + // 비고는 맨 끝 (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_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index e07dbf98..4768fecd 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -167,6 +167,8 @@ export interface UploadOverviewFile { uploaded_at: string | null; /** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */ relative_path: string | null; + /** 업로드 분석 결과 — 카드 미리보기(범위 사각형·노선 선)의 재료. */ + metadata?: Record | null; } export interface UploadOverviewSession { diff --git a/B03_FileInput/B03_FileInput_Email.py b/B03_FileInput/B03_FileInput_Email.py index 0d7d2001..4da46696 100644 --- a/B03_FileInput/B03_FileInput_Email.py +++ b/B03_FileInput/B03_FileInput_Email.py @@ -6,7 +6,7 @@ from typing import Any from uuid import UUID from common_util.common_util_email import send_email -from config.config_system import APP_PUBLIC_BASE_URL +from config.config_system import ADMIN_EMAIL, APP_PUBLIC_BASE_URL logger = logging.getLogger(__name__) @@ -285,3 +285,53 @@ async def send_analysis_error_email( ) logger.info("WF1 오류 이메일 발송 %s: %s", "성공" if success else "실패", to_email) return success + + +async def send_initial_design_failed_email( + *, + project_id: UUID, + project_name: str, + to_email: str, + reason: str, +) -> bool: + """초기 설계(B05·B06 자동 계산) 실패를 알리고 관리자 문의를 안내한다. + + 부분 결과를 초기값으로 삼지 않는다는 방침(2026-09-02 사용자 확정)에 따라, 체인이 + 깨진 프로젝트는 [초기화]가 되돌릴 기준이 없다. 완료 메일 대신 이 메일을 보내 + 사용자가 화면에서 헛작업을 하지 않게 한다. + """ + contact = ADMIN_EMAIL.strip() + contact_line = ( + f'

관리자에게 문의해 주세요 — ' + f"{html.escape(contact)}

" + if contact + else "

관리자에게 문의해 주세요.

" + ) + rows = "\n".join( + [ + _summary_row("프로젝트 ID", html.escape(str(project_id))), + _summary_row("중단 지점", html.escape(reason or "초기 설계 처리 실패")), + ] + ) + safe_name = html.escape(project_name) + body = f""" +

프로젝트 {safe_name}초기 설계 자동 계산이 + 완료되지 못했습니다.

+
+ {rows} +
+
+

초기 설계가 끝나지 않아 종·횡단 화면의 값이 기준값으로 확정되지 않았습니다. + 이 상태에서는 [초기화]로 되돌릴 초기값도 없습니다.

+ {contact_line} +
+

기술 지원팀이 확인할 수 있도록 서버 로그에도 같은 내용을 기록했습니다.

+ """ + subject = f"초기 설계 실패 알림 - {project_name}" + success = await send_email( + to_email=to_email, + subject=subject, + html=_email_shell(subject, body, accent="#dc2626"), + ) + logger.info("초기 설계 실패 이메일 발송 %s: %s", "성공" if success else "실패", to_email) + return success diff --git a/B03_FileInput/B03_FileInput_Engine_Analyze.py b/B03_FileInput/B03_FileInput_Engine_Analyze.py index 50319d5f..60c043a3 100644 --- a/B03_FileInput/B03_FileInput_Engine_Analyze.py +++ b/B03_FileInput/B03_FileInput_Engine_Analyze.py @@ -1,9 +1,13 @@ """B03 원본 입력 파일 메타데이터 분석.""" +import base64 +import codecs import csv +import io import logging import math import re +import struct from pathlib import Path from threading import get_ident from typing import Any @@ -11,6 +15,7 @@ from typing import Any import laspy import numpy as np import rasterio +from PIL import Image from pyproj import CRS logger = logging.getLogger(__name__) @@ -136,6 +141,11 @@ def _prepare_prj_wkt(text: str) -> tuple[str, list[str]]: return _CUSTOM_VERTICAL_AUTHORITY_PATTERN.sub("", text), codes +# 카드 미리보기 점 그림의 점 수 상한과, 분류가 없는 파일에서 볼 청크 수 (2026-09-04). +_LAS_PREVIEW_MAX_POINTS = 5000 +_LAS_PREVIEW_MAX_CHUNKS = 3 + + def analyze_las_metadata(path: str | Path) -> dict[str, Any]: """LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다.""" source = Path(path) @@ -170,6 +180,22 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]: "has_return_number": "return_number" in dimension_names, } + # 카드 미리보기용 탑뷰 점 그림 (2026-09-04 사용자 지시). + # 분류 통계를 훑는 **그 길에** XY 를 성기게 주워 둔다 — 파일을 다시 읽지 않으므로 + # 업로드 시간이 늘지 않는다. 분류가 없어 훑지 않는 파일만 앞 몇 청크를 본다. + preview_points: list[list[int]] = [] + stride = max(1, point_count // _LAS_PREVIEW_MAX_POINTS) if point_count else 1 + + def _collect(chunk: Any) -> None: + if len(preview_points) >= _LAS_PREVIEW_MAX_POINTS: + return + xs = np.asarray(chunk.x, dtype=np.float64)[::stride] + ys = np.asarray(chunk.y, dtype=np.float64)[::stride] + room = _LAS_PREVIEW_MAX_POINTS - len(preview_points) + # 카드 안 작은 그림이라 1m 눈금이면 충분하다 — 정수로 줄여 저장 용량을 아낀다. + for x, y in zip(xs[:room].tolist(), ys[:room].tolist(), strict=True): + preview_points.append([round(x), round(y)]) + if metadata["has_classification"] and point_count > 0: classification_counts: dict[int, int] = {} for chunk in las_file.chunk_iterator(500_000): @@ -179,9 +205,22 @@ def analyze_las_metadata(path: str | Path) -> dict[str, Any]: ) for value, count in zip(values.tolist(), counts.tolist(), strict=True): classification_counts[value] = classification_counts.get(value, 0) + count + _collect(chunk) metadata["classification_summary"] = { str(key): value for key, value in sorted(classification_counts.items()) } + elif point_count > 0: + # 분류가 없으면 전 점을 훑을 이유가 없다 — 앞 몇 청크만 보고 끝낸다. + for index, chunk in enumerate(las_file.chunk_iterator(500_000)): + _collect(chunk) + if ( + index + 1 >= _LAS_PREVIEW_MAX_CHUNKS + or len(preview_points) >= _LAS_PREVIEW_MAX_POINTS + ): + break + + if preview_points: + metadata["preview_points"] = preview_points return metadata @@ -218,11 +257,43 @@ def analyze_prj_metadata(path: str | Path) -> dict[str, Any]: "authority": crs.to_authority(), "custom_authority_codes": custom_authority_codes, "is_valid": True, + # 카드에 보일 값 — 좌표계 이름만으로는 어느 원점인지 안 보인다(2026-09-04 지시). + **_prj_facts(crs), } ) return metadata +def _prj_facts(crs: Any) -> dict[str, Any]: + """좌표계에서 카드에 보일 값 — 중앙자오선(원점 경도)·길이 단위.""" + from common_util.common_util_crs import strip_bound + + flattened = strip_bound(crs) + horizontal = next( + ( + strip_bound(item) + for item in (flattened.sub_crs_list or [flattened]) + if strip_bound(item).is_projected or strip_bound(item).is_geographic + ), + None, + ) + if horizontal is None: + return {} + facts: dict[str, Any] = {} + # 원점 경도는 투영 매개변수에서 곧장 읽는다 — proj 문자열로 바꾸면 정보가 깎인다. + operation = horizontal.coordinate_operation + for param in operation.params if operation is not None else []: + label = (param.name or "").lower() + if "longitude" in label and ("origin" in label or "meridian" in label): + if isinstance(param.value, (int, float)): + facts["central_meridian_deg"] = float(param.value) + break + axes = list(horizontal.axis_info or []) + if axes and axes[0].unit_name: + facts["unit_name"] = axes[0].unit_name + return facts + + def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: """TFW의 affine 변환 계수와 유효성을 분석한다.""" source = Path(path) @@ -247,6 +318,43 @@ def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]: } +# 카드 미리보기 썸네일 한 변 크기(px) — 128 이면 카드 폭에서 충분히 읽힌다 (2026-09-04). +_TIF_THUMBNAIL_PX = 128 + + +def _geotiff_thumbnail(dataset: Any) -> str | None: + """저해상 흑백 썸네일을 data URL(PNG) 로 만든다. 만들 수 없으면 None. + + 오버뷰(피라미드)가 있는 파일만 대상으로 한다 — 오버뷰가 없으면 원본을 훑어 축소해야 + 해서 업로드가 눈에 띄게 느려진다(사용자 제약 「오래 걸리면 안 됨」). + """ + try: + if not any(dataset.overviews(index) for index in dataset.indexes): + return None + band = dataset.read( + 1, + out_shape=(_TIF_THUMBNAIL_PX, _TIF_THUMBNAIL_PX), + masked=True, + ) + finite = band.compressed() + if finite.size == 0: + return None + low = float(np.percentile(finite, 2)) + high = float(np.percentile(finite, 98)) + if high <= low: + return None + # 2~98 백분위로 늘려 대비를 준다 — DEM 은 값 폭이 좁아 그냥 펴면 밋밋하다. + scaled = np.clip((band.filled(low) - low) / (high - low), 0.0, 1.0) + grey = (scaled * 255).astype(np.uint8) + image = Image.fromarray(grey, mode="L") + buffer = io.BytesIO() + image.save(buffer, format="PNG", optimize=True) + return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + except Exception as exc: # 썸네일은 곁가지다 — 실패해도 분석을 막지 않는다. + logger.warning("GeoTIFF 썸네일 생성 실패: %s", exc) + return None + + def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: """TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다.""" source = Path(path) @@ -261,6 +369,9 @@ def analyze_tif_metadata(path: str | Path) -> dict[str, Any]: bounds = dataset.bounds return { "file": source.name, + # 카드 미리보기용 흑백 썸네일 (2026-09-04 사용자 지시). + # **오버뷰가 있을 때만** 만든다 — 없는 큰 파일을 축소 읽으면 수 초가 걸린다. + "preview_thumbnail": _geotiff_thumbnail(dataset), "width": int(dataset.width), "height": int(dataset.height), "count": int(dataset.count), @@ -390,6 +501,126 @@ def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]: } +# DBF 머리글 최대 크기 — 32바이트 고정 + 속성 255개 × 32바이트 + 끝 표시. +_DBF_HEADER_MAX = 32 + 255 * 32 + 1 + + +def _dbf_field_defs(head: bytes) -> list[tuple[str, str, int]]: + """머리글의 속성 서술자에서 (이름, 형식, 길이)를 뽑는다.""" + fields: list[tuple[str, str, int]] = [] + for start in range(32, len(head) - 31, 32): + block = head[start : start + 32] + if block[0] in (0x0D, 0x00): + break + name = block[:11].partition(bytes(1))[0].decode("cp949", errors="replace").strip() + if name: + fields.append((name, chr(block[11]), block[16])) + return fields + + +def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]: + """DBF 머리글만 읽어 레코드 수·속성 수·**속성 이름**을 낸다 (2026-09-04 카드 표시용). + + 머리글은 최대 8KB라 파일 크기와 무관하게 즉시 끝난다. 속성 이름은 카드에서 + 「이 표에 무엇이 들었나」를 한눈에 보이는 값이다(2026-09-04 사용자 지시). + """ + source = Path(path) + with source.open("rb") as handle: + head = handle.read(_DBF_HEADER_MAX) + metadata: dict[str, Any] = { + "file": source.name, + "extension": "dbf", + "size_bytes": source.stat().st_size, + } + if len(head) >= 12: + record_count = int.from_bytes(head[4:8], "little") + header_length = int.from_bytes(head[8:10], "little") + metadata["record_count"] = record_count + # 머리글 = 32바이트 고정 + 속성마다 32바이트 + 끝 표시 1바이트. + metadata["field_count"] = max(0, (header_length - 33) // 32) + metadata["field_names"] = [name for name, _type, _length in _dbf_field_defs(head)] + return metadata + + +def analyze_shx_metadata(path: str | Path) -> dict[str, Any]: + """SHX 머리글에서 도형 개수를 센다 (2026-09-04 카드 표시용). + + SHX 는 도형마다 8바이트 색인이 한 줄씩이라 파일 길이로 개수가 나온다. + """ + source = Path(path) + head = source.read_bytes()[:100] + metadata: dict[str, Any] = { + "file": source.name, + "extension": "shx", + "size_bytes": source.stat().st_size, + } + if len(head) >= 28: + # 24~27바이트: 파일 길이(16비트 워드 단위, 빅엔디안). + words = int.from_bytes(head[24:28], "big") + metadata["shape_count"] = max(0, (words * 2 - 100) // 8) + if len(head) >= 68: + # 32~35바이트: 도형 종류. 36~67바이트: 범위(Xmin, Ymin, Xmax, Ymax). + metadata["shape_type"] = int.from_bytes(head[32:36], "little") + x_min, y_min, x_max, y_max = struct.unpack("<4d", head[36:68]) + if x_max > x_min and y_max > y_min: + metadata["extent_width_m"] = x_max - x_min + metadata["extent_height_m"] = y_max - y_min + return metadata + + +def _dbf_text_sample(dbf: Path, encoding: str) -> str | None: + """같은 세트 DBF 의 첫 글자 속성 값을 그 인코딩으로 읽어 본다. + + CPG 는 이름 한 줄이 전부라, 그 이름이 맞는지는 **글자가 깨지는지**로만 보인다 + (2026-09-04 사용자 지시). 머리글 + 레코드 한 줄만 읽는다. + """ + try: + with dbf.open("rb") as handle: + head = handle.read(_DBF_HEADER_MAX) + if len(head) < 32: + return None + header_length = int.from_bytes(head[8:10], "little") + fields = _dbf_field_defs(head) + offset = 1 # 레코드 첫 바이트는 삭제 표시 + for _name, kind, length in fields: + if kind == "C": + handle.seek(header_length + offset) + raw = handle.read(length) + return raw.decode(encoding, errors="replace").strip() or None + offset += length + except (OSError, LookupError, ValueError): + return None + return None + + +def _codec_name(label: str) -> str | None: + """CPG 가 적은 이름을 파이썬 코덱 이름으로 바꾼다 (숫자만 적힌 것은 코드페이지).""" + candidate = label.strip() + if candidate.isdigit(): + candidate = f"cp{candidate}" + try: + return codecs.lookup(candidate).name + except LookupError: + return None + + +def analyze_cpg_metadata(path: str | Path) -> dict[str, Any]: + """CPG 는 인코딩 이름 한 줄이 전부다 — 같은 세트 DBF 의 글자 견본을 함께 낸다.""" + source = Path(path) + text = source.read_text(encoding="utf-8", errors="replace").strip() + metadata: dict[str, Any] = { + "file": source.name, + "extension": "cpg", + "size_bytes": source.stat().st_size, + "encoding": text or None, + } + codec = _codec_name(text) if text else None + dbf = source.with_suffix(".dbf") + if codec and dbf.is_file(): + metadata["encoding_sample"] = _dbf_text_sample(dbf, codec) + return metadata + + def analyze_input_metadata(path: str | Path) -> dict[str, Any]: """입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다.""" source = Path(path) @@ -406,6 +637,13 @@ def analyze_input_metadata(path: str | Path) -> dict[str, Any]: return analyze_prj_metadata(source) if extension == ".tfw": return analyze_tfw_metadata(source) + # 부속 파일도 카드에 「구분되는 값」을 보여 준다 (2026-09-04 사용자 지시). + if extension == ".dbf": + return analyze_dbf_metadata(source) + if extension == ".shx": + return analyze_shx_metadata(source) + if extension == ".cpg": + return analyze_cpg_metadata(source) if extension in {".tif", ".tiff"}: return analyze_tif_metadata(source) return { diff --git a/B03_FileInput/B03_FileInput_Engine_Shapefile.py b/B03_FileInput/B03_FileInput_Engine_Shapefile.py index aeb56ce8..f67e94b8 100644 --- a/B03_FileInput/B03_FileInput_Engine_Shapefile.py +++ b/B03_FileInput/B03_FileInput_Engine_Shapefile.py @@ -213,6 +213,33 @@ def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> st return fallback +_PREVIEW_MAX_POINTS = 200 + + +def _thin_preview_path( + parts: list[list[tuple]], limit: int = _PREVIEW_MAX_POINTS +) -> list[list[list[float]]]: + """카드 미리보기용으로 노선 정점을 솎는다 — 파일을 다시 읽지 않는다. + + 전 정점은 이미 메모리에 있고(`read_shapefile_parts`), 화면에 그릴 선은 60~80px + 높이라 200점이면 모양이 충분히 산다. 파트별로 시작·끝점은 반드시 남긴다. + """ + total = sum(len(part) for part in parts) + if total == 0: + return [] + step = max(1, total // limit) + preview: list[list[list[float]]] = [] + for part in parts: + if not part: + continue + thinned = [[float(point[0]), float(point[1])] for point in part[::step]] + last = [float(part[-1][0]), float(part[-1][1])] + if thinned[-1] != last: + thinned.append(last) + preview.append(thinned) + return preview + + def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: """계획노선 shapefile의 B03 메타데이터를 만든다.""" source = Path(path) @@ -241,4 +268,6 @@ def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: "bounds": header["bounds"], "start_point": list(parts[0][0]) if parts else None, "end_point": list(parts[-1][-1]) if parts else None, + # 카드 미리보기용 솎은 좌표열 — 파일 재열람 없음(2026-09-04 사용자 지시). + "preview_path": _thin_preview_path(parts), } diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index de194576..1c73b7dc 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -327,7 +327,7 @@ async def list_project_input_files( await cursor.execute( """ SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status, - f.upload_at, f.raw_file_path + f.upload_at, f.raw_file_path, f.metadata FROM input_files f INNER JOIN ( SELECT MAX(id) AS id diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index d63f236c..2af94831 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -5,302 +5,124 @@ import json import logging from pathlib import Path from typing import Any -from uuid import UUID, uuid4 +from uuid import UUID import aiomysql from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse -from B03_FileInput.B03_FileInput_Email import ( - send_file_upload_complete_email, -) from B03_FileInput.B03_FileInput_Engine import ( - merge_upload_chunks, - remove_chunk_session, - resolve_chunk_session_dir, resolve_upload_destination, - save_upload_chunk, save_upload_stream, ) from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, - create_upload_session, - find_input_file_by_name, get_project_input_readiness, get_project_storage_relative_path, - get_upload_session, - list_completed_chunk_indexes, list_incomplete_upload_sessions, list_project_input_files, - mark_upload_session_completed, - mark_upload_session_failed, - supersede_previous_input_files, - upsert_upload_chunk, +) + +# 분리 전 이 파일에 있던 이름은 그대로 다시 내보낸다 — 옛 이름을 참조하는 +# 테스트·스크립트가 깨지지 않게 하기 위함이다(2026-09-04). +from B03_FileInput.B03_FileInput_Router_Chunks import ( + create_project_upload_session as create_project_upload_session, +) +from B03_FileInput.B03_FileInput_Router_Chunks import ( + finalize_project_upload as finalize_project_upload, +) +from B03_FileInput.B03_FileInput_Router_Chunks import ( + get_project_upload_status as get_project_upload_status, +) +from B03_FileInput.B03_FileInput_Router_Chunks import router as chunk_router +from B03_FileInput.B03_FileInput_Router_Chunks import ( + upload_project_chunk as upload_project_chunk, ) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _ANALYSIS_RUNNING_MESSAGE as _ANALYSIS_RUNNING_MESSAGE, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _BACKGROUND_TASKS as _BACKGROUND_TASKS, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _POINT_CLOUD_FILE_TYPES as _POINT_CLOUD_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _REQUIRED_FILE_TYPES as _REQUIRED_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _ROUTE_FILE_TYPES as _ROUTE_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _already_uploaded as _already_uploaded, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _complete_file_input_if_ready as _complete_file_input_if_ready, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _get_project_notification_info as _get_project_notification_info, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _is_point_cloud_result as _is_point_cloud_result, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _missing_required_file_types as _missing_required_file_types, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _require_complete_file_set as _require_complete_file_set, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _schedule_background_task as _schedule_background_task, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _send_upload_complete_notification as _send_upload_complete_notification, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _stored_fingerprint as _stored_fingerprint, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _total_chunks as _total_chunks, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _update_project_status as _update_project_status, +) +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 ( - ChunkSessionCreateRequest, - ChunkSessionCreateResponse, - ChunkUploadResponse, FileUploadDescriptor, FileUploadResponse, UploadedFileResult, - UploadFinalizeRequest, UploadOverviewFile, UploadOverviewResponse, UploadOverviewSession, - UploadStatusResponse, ) from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email from common_util.common_util_auth import verify_session -from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot from common_util.common_util_json import atomic_write_json -from common_util.common_util_project_reset import purge_project_outputs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow_state import ( - complete_stage, get_workflow_state, - is_analysis_running, - reset_stages_after_input_change, ) from config.config_db import get_db_pool from config.config_system import ( - UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_FILES, ) -_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요." - logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) -_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"}) -_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) -# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile). -_ROUTE_FILE_TYPES = frozenset({"csv", "shp"}) -# shapefile은 이것들이 다 있어야 열린다. `.cpg`는 없으면 CP949로 읽으므로 필수가 아니다. -# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다. -_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"}) - - -def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: - return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) - - -def _is_point_cloud_result(result: UploadedFileResult) -> bool: - """포인트클라우드 결과인지 — 임시 보관함 안내 메일 경로에서 쓴다. - - 프로젝트 업로드 경로는 더 이상 이 판정으로 메일을 보내지 않는다 - ([[_send_upload_complete_notification]] 주석 참고). - """ - return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES - - -def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: - missing = 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 - - -def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None: - missing = _missing_required_file_types(file_types, las_free) - if missing: - raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") - - -def _stored_fingerprint(metadata: Any) -> str | None: - """입력 파일 메타데이터에 적어 둔 지문을 꺼낸다.""" - if isinstance(metadata, str): - try: - metadata = json.loads(metadata) - except (TypeError, ValueError): - return None - if not isinstance(metadata, dict): - return None - value = metadata.get("fingerprint") - return str(value) if value else None - - -async def _already_uploaded( - connection: aiomysql.Connection, - project_id: UUID, - payload: ChunkSessionCreateRequest, -) -> ChunkSessionCreateResponse | None: - """같은 이름으로 **같은 내용**이 이미 올라와 있으면 전송을 건너뛰라는 응답을 만든다. - - 1.7GB를 다 받은 뒤에 비교하면 아낄 게 없으므로, 세션을 만들기 전에 화면이 보내 준 - 지문으로 가린다. 지문이 없거나 다르면 그냥 올린다 — 애매하면 올리는 쪽이 안전하다. - """ - if not payload.fingerprint: - return None - existing = await find_input_file_by_name(connection, project_id, payload.original_filename) - if not existing or _stored_fingerprint(existing.get("metadata")) != payload.fingerprint: - return None - logger.info( - "B03 같은 파일 재업로드 — 전송 생략: project_id=%s file=%s", - project_id, - payload.original_filename, - ) - return ChunkSessionCreateResponse( - project_id=str(project_id), - upload_session_id="", - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=payload.chunk_size_bytes, - total_chunks=0, - already_uploaded=True, - ) - - -async def _complete_file_input_if_ready( - connection: aiomysql.Connection, - project_id: UUID, - las_free: bool = False, -) -> int: - file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( - connection, project_id - ) - _require_complete_file_set(file_types, las_free) - if point_cloud_input_id is None: - if not las_free: - raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") - if route_csv_input_id is None: - raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.") - # 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면 - # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - clear_designing(project_root) - discard_initial_snapshot(project_root) - await purge_project_outputs(connection, str(project_id), project_root) - async with connection.cursor(aiomysql.DictCursor) as cursor: - await reset_stages_after_input_change(cursor, str(project_id)) - await complete_stage(cursor, str(project_id), 0) - # LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다. - return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id) - - -def _write_stage_metadata( - stage_root: Path, - project_id: UUID, - results: list[UploadedFileResult], -) -> None: - metadata_path = stage_root / "metadata.json" - existing_files: list[dict[str, Any]] = [] - if metadata_path.exists(): - try: - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - existing_files = list(payload.get("files") or []) - except (OSError, TypeError, ValueError): - logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) - - merged = { - str(item.get("relative_path") or item.get("original_filename")): item - for item in existing_files - } - for result in results: - dumped = result.model_dump() - merged[result.relative_path] = dumped - atomic_write_json( - metadata_path, - {"project_id": str(project_id), "files": list(merged.values())}, - ) - - -# 실행 중인 백그라운드 작업의 강한 참조. 이벤트 루프는 작업을 약한 참조로만 들고 있어, -# 여기서 붙잡지 않으면 GC가 대기 중인 작업을 통째로 회수해 WF1 분석이 조용히 사라진다 -# (업로드는 성공·stage 0은 COMPLETE인데 stage 1은 NOT_STARTED로 남는 증상). -_BACKGROUND_TASKS: set[asyncio.Task] = set() - - -def _schedule_background_task(coro: Any, *, task_name: str) -> None: - task = asyncio.create_task(coro, name=task_name) - _BACKGROUND_TASKS.add(task) - - def _log_task_failure(completed: asyncio.Task) -> None: - _BACKGROUND_TASKS.discard(completed) - try: - completed.result() - except asyncio.CancelledError: - logger.warning("백그라운드 작업 취소됨: %s", task_name) - except Exception: - logger.exception("백그라운드 작업 실패: %s", task_name) - - task.add_done_callback(_log_task_failure) - logger.info("백그라운드 작업 시작: %s", task_name) - - -async def _get_project_notification_info( - connection: aiomysql.Connection, - project_id: UUID, -) -> dict[str, Any] | None: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """ - SELECT - p.id, - p.name AS project_name, - u.email AS user_email, - u.name AS user_name - FROM projects p - JOIN users u ON u.id = p.user_id - WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL - """, - (str(project_id),), - ) - row = await cursor.fetchone() - return dict(row) if row else None - - -async def _update_project_status(project_id: UUID, status: str) -> None: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """ - UPDATE projects - SET status = %s, updated_at = NOW() - WHERE id = %s AND deleted_at IS NULL - """, - (status, str(project_id)), - ) - await connection.commit() - - -async def _send_upload_complete_notification( - *, - project_id: UUID, - uploaded_file: UploadedFileResult, -) -> None: - """저장만 끝났을 때 보내는 안내. - - 프로젝트 업로드 경로에서는 호출하지 않는다 — 그 흐름은 초기 설계까지 마친 뒤 - 통합 메일 한 통으로 알린다. 프로젝트 생성 전 임시 보관함 업로드에서 쓸 예정이라 - 지워두지 않았다(2026-08-08 사용자 지시). - """ - pool = get_db_pool() - async with pool.acquire() as connection: - project_info = await _get_project_notification_info(connection, project_id) - if not project_info or not project_info.get("user_email"): - logger.warning("업로드 완료 이메일 수신자 없음: project_id=%s", project_id) - return - - await send_file_upload_complete_email( - to_email=str(project_info["user_email"]), - user_name=str(project_info.get("user_name") or "사용자"), - project_name=str(project_info.get("project_name") or project_id), - file_name=uploaded_file.original_filename, - file_size_mb=uploaded_file.size_bytes / (1024 * 1024), - metadata=uploaded_file.metadata, - ) +# 청크 업로드 엔드포인트는 파일이 700줄을 넘어 떼어냈다(2026-09-04) — 경로는 그대로다. +router.include_router(chunk_router) @router.post("/{project_id}/files", response_model=FileUploadResponse) @@ -337,24 +159,27 @@ 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개 이상 포함해야 합니다.", }, ) - csv_count = sum(Path(filename).suffix.lower() == ".csv" for filename in filenames) - if csv_count != 1: + # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 + # (2026-09-04 사용자 지시: 사용자는 CSV 를 쓰지 않음. CSV 는 내부 정본 한 벌뿐). + route_count = sum(Path(filename).suffix.lower() in {".csv", ".shp"} for filename in filenames) + if route_count != 1: return JSONResponse( status_code=400, content={ "status": "error", - "message": "계획노선 CSV 파일을 정확히 1개 포함해야 합니다.", + "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( @@ -458,327 +283,17 @@ async def upload_project_files( await upload.close() -@router.post("/{project_id}/upload-sessions", response_model=ChunkSessionCreateResponse) -async def create_project_upload_session( - project_id: UUID, - payload: ChunkSessionCreateRequest, - session: dict[str, Any] = Depends(verify_session), -) -> ChunkSessionCreateResponse | JSONResponse: - """대용량 파일 청크 업로드 세션을 생성한다.""" - # LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로 - # 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다. - if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}: - return JSONResponse( - status_code=400, - content={ - "status": "error", - "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", - }, - ) - chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) - total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) - session_id = str(uuid4()) - point_cloud_input_id: int | None = None - skipped: ChunkSessionCreateResponse | None = None - - pool = get_db_pool() +def _parse_metadata(raw: Any) -> dict[str, Any] | None: + """DB에 JSON 문자열로 저장된 분석 메타데이터를 dict로 돌린다(깨지면 생략).""" + if isinstance(raw, dict): + return raw + if not raw: + return None try: - async with pool.acquire() as connection: - await get_project_storage_relative_path(connection, project_id) - # 분석이 도는 중이면 새 자료를 받지 않는다 — 받아 봐야 분석 2개가 같은 산출물 - # 경로에서 부딪힌다. 화면도 버튼을 잠그지만 새로고침으로 우회할 수 있어 여기서 막는다. - async with connection.cursor(aiomysql.DictCursor) as cursor: - if await is_analysis_running(cursor, str(project_id)): - return JSONResponse( - status_code=409, - content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE}, - ) - skipped = await _already_uploaded(connection, project_id, payload) - if skipped is not None: - if payload.complete_upload: - await connection.begin() - try: - point_cloud_input_id = await _complete_file_input_if_ready( - connection, - project_id, - payload.las_free, - ) - await connection.commit() - except Exception: - await connection.rollback() - raise - else: - await create_upload_session( - connection, - session_id=session_id, - project_id=project_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - if skipped is not None: - if point_cloud_input_id is not None: - _schedule_background_task( - trigger_wf1_analysis_and_email( - project_id=project_id, - input_file_id=point_cloud_input_id, - user_role=str(session["role"]), - ), - task_name=f"b04-preprocess-auto-{project_id}", - ) - return skipped - return ChunkSessionCreateResponse( - project_id=str(project_id), - upload_session_id=session_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B03 청크 세션 생성 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, - ) - - -@router.post("/{project_id}/chunks", response_model=ChunkUploadResponse) -async def upload_project_chunk( - project_id: UUID, - session_id: str = Form(...), - chunk_index: int = Form(...), - chunk_data: UploadFile = File(...), -) -> ChunkUploadResponse | JSONResponse: - """단일 파일 청크를 B03 임시 폴더에 저장하고 DB에 기록한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - upload_session = await get_upload_session( - connection, - project_id=project_id, - session_id=session_id, - ) - if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): - return JSONResponse( - status_code=400, - content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, - ) - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - session_dir = resolve_chunk_session_dir(project_root, session_id) - chunk_path, size_bytes, chunk_hash = await save_upload_chunk( - chunk_data, - session_dir, - chunk_index, - expected_max_bytes=int(upload_session["chunk_size_bytes"]), - ) - relative_chunk_path = chunk_path.relative_to(project_root).as_posix() - completed_chunks = await upsert_upload_chunk( - connection, - session_id=session_id, - chunk_index=chunk_index, - chunk_hash=chunk_hash, - size_bytes=size_bytes, - stored_at=relative_chunk_path, - ) - return ChunkUploadResponse( - upload_session_id=session_id, - chunk_index=chunk_index, - completed_chunks=completed_chunks, - total_chunks=int(upload_session["total_chunks"]), - chunk_hash=chunk_hash, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception( - "B03 청크 업로드 실패: project_id=%s session_id=%s", - project_id, - session_id, - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, - ) - finally: - await chunk_data.close() - - -@router.post("/{project_id}/finalize", response_model=FileUploadResponse) -async def finalize_project_upload( - project_id: UUID, - payload: UploadFinalizeRequest, - session: dict[str, Any] = Depends(verify_session), -) -> FileUploadResponse | JSONResponse: - """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" - pool = get_db_pool() - final_path: Path | None = None - point_cloud_input_id: int | None = None - try: - async with pool.acquire() as connection: - upload_session = await get_upload_session( - connection, - project_id=project_id, - session_id=payload.session_id, - ) - if int(upload_session["total_chunks"]) != payload.total_chunks: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, - ) - completed_indexes = await list_completed_chunk_indexes( - connection, - session_id=payload.session_id, - ) - expected_indexes = list(range(payload.total_chunks)) - if completed_indexes != expected_indexes: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, - ) - - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - descriptor = FileUploadDescriptor( - original_filename=str(upload_session["original_filename"]), - size_bytes=int(upload_session["file_size_bytes"]), - ) - final_path = merge_upload_chunks( - project_root, - descriptor, - payload.session_id, - payload.total_chunks, - ) - metadata = await asyncio.to_thread(analyze_input_metadata, final_path) - # 다음에 같은 파일이 올라오면 전송을 건너뛸 수 있도록 지문을 함께 남긴다. - fingerprint = payload.fingerprint or None - if fingerprint: - metadata = {**metadata, "fingerprint": fingerprint} - relative_path = final_path.relative_to(project_root).as_posix() - file_type = final_path.suffix.lower().lstrip(".") - crs_epsg = metadata.get("epsg") - - await connection.begin() - try: - input_file_id = await create_input_file( - connection, - project_id=project_id, - file_type=file_type, - original_filename=descriptor.original_filename, - relative_path=relative_path, - file_size_bytes=descriptor.size_bytes, - upload_by=None, - crs_epsg=int(crs_epsg) if crs_epsg is not None else None, - metadata=metadata, - ) - # 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다. - await supersede_previous_input_files( - connection, - project_id, - descriptor.original_filename, - input_file_id, - ) - await mark_upload_session_completed(connection, session_id=payload.session_id) - if payload.complete_upload: - point_cloud_input_id = await _complete_file_input_if_ready( - connection, - project_id, - payload.las_free, - ) - await connection.commit() - except Exception: - await connection.rollback() - await mark_upload_session_failed(connection, session_id=payload.session_id) - raise - - remove_chunk_session(project_root, payload.session_id) - result = UploadedFileResult( - input_file_id=input_file_id, - original_filename=descriptor.original_filename, - file_type=file_type, - relative_path=relative_path, - size_bytes=descriptor.size_bytes, - metadata=metadata, - ) - stage_root = project_root / "B03_FileInput" - _write_stage_metadata(stage_root, project_id, [result]) - # 업로드 직후 안내 메일은 보내지 않는다 — 위 일반 업로드 경로와 같은 이유. - if point_cloud_input_id is not None: - _schedule_background_task( - trigger_wf1_analysis_and_email( - project_id=project_id, - input_file_id=point_cloud_input_id, - user_role=str(session["role"]), - ), - task_name=f"b04-preprocess-auto-{project_id}", - ) - return FileUploadResponse(project_id=str(project_id), files=[result]) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - if final_path is not None: - final_path.unlink(missing_ok=True) - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - if final_path is not None: - final_path.unlink(missing_ok=True) - logger.exception("B03 청크 업로드 최종 병합 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, - ) - - -@router.get("/{project_id}/upload-status/{session_id}", response_model=UploadStatusResponse) -async def get_project_upload_status( - project_id: UUID, - session_id: str, -) -> UploadStatusResponse | JSONResponse: - """업로드 세션의 청크 완료 상태를 조회한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - upload_session = await get_upload_session( - connection, - project_id=project_id, - session_id=session_id, - ) - completed_indexes = await list_completed_chunk_indexes( - connection, - session_id=session_id, - ) - return UploadStatusResponse( - upload_session_id=session_id, - upload_status=str(upload_session["status"]), - original_filename=str(upload_session["original_filename"]), - file_size_bytes=int(upload_session["file_size_bytes"]), - chunk_size_bytes=int(upload_session["chunk_size_bytes"]), - total_chunks=int(upload_session["total_chunks"]), - completed_chunks=len(completed_indexes), - completed_chunk_indexes=completed_indexes, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception( - "B03 업로드 상태 조회 실패: project_id=%s session_id=%s", - project_id, - session_id, - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, - ) + parsed = json.loads(raw) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, dict) else None @router.get("/{project_id}/upload-overview", response_model=UploadOverviewResponse) @@ -821,6 +336,7 @@ async def get_project_upload_overview( status=str(row["status"]), uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None, relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None), + metadata=_parse_metadata(row.get("metadata")), ) for row in files ], @@ -838,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_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py new file mode 100644 index 00000000..0ded7774 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -0,0 +1,389 @@ +"""B03 청크 업로드 엔드포인트 — 세션 생성·조각 전송·마무리·진행 조회. + +라우터 본체(`B03_FileInput_Router.py`)가 700줄을 넘어 떼어낸 조각이다(2026-09-04). +경로·태그는 본체와 같다 — 본체가 `include_router` 로 붙여 URL 이 그대로 유지된다. +""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import aiomysql +from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Engine import ( + merge_upload_chunks, + remove_chunk_session, + resolve_chunk_session_dir, + save_upload_chunk, +) +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + create_input_file, + create_upload_session, + get_project_storage_relative_path, + get_upload_session, + list_completed_chunk_indexes, + mark_upload_session_completed, + mark_upload_session_failed, + supersede_previous_input_files, + upsert_upload_chunk, +) +from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _ANALYSIS_RUNNING_MESSAGE, + _already_uploaded, + _complete_file_input_if_ready, + _schedule_background_task, + _total_chunks, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + ChunkUploadResponse, + FileUploadDescriptor, + FileUploadResponse, + UploadedFileResult, + UploadFinalizeRequest, + UploadStatusResponse, +) +from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email +from common_util.common_util_auth import verify_session +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import ( + is_analysis_running, +) +from config.config_db import get_db_pool +from config.config_system import ( + UPLOAD_CHUNK_SIZE_BYTES, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["B03 File Input"]) + + +@router.post("/{project_id}/upload-sessions", response_model=ChunkSessionCreateResponse) +async def create_project_upload_session( + project_id: UUID, + payload: ChunkSessionCreateRequest, + session: dict[str, Any] = Depends(verify_session), +) -> ChunkSessionCreateResponse | JSONResponse: + """대용량 파일 청크 업로드 세션을 생성한다.""" + # LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로 + # 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다. + if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", + }, + ) + chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) + total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) + session_id = str(uuid4()) + point_cloud_input_id: int | None = None + skipped: ChunkSessionCreateResponse | None = None + + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await get_project_storage_relative_path(connection, project_id) + # 분석이 도는 중이면 새 자료를 받지 않는다 — 받아 봐야 분석 2개가 같은 산출물 + # 경로에서 부딪힌다. 화면도 버튼을 잠그지만 새로고침으로 우회할 수 있어 여기서 막는다. + async with connection.cursor(aiomysql.DictCursor) as cursor: + if await is_analysis_running(cursor, str(project_id)): + return JSONResponse( + status_code=409, + content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE}, + ) + skipped = await _already_uploaded(connection, project_id, payload) + if skipped is not None: + if payload.complete_upload: + await connection.begin() + try: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + payload.las_free, + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + else: + await create_upload_session( + connection, + session_id=session_id, + project_id=project_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + if skipped is not None: + if point_cloud_input_id is not None: + _schedule_background_task( + trigger_wf1_analysis_and_email( + project_id=project_id, + input_file_id=point_cloud_input_id, + user_role=str(session["role"]), + ), + task_name=f"b04-preprocess-auto-{project_id}", + ) + return skipped + return ChunkSessionCreateResponse( + project_id=str(project_id), + upload_session_id=session_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B03 청크 세션 생성 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, + ) + + +@router.post("/{project_id}/chunks", response_model=ChunkUploadResponse) +async def upload_project_chunk( + project_id: UUID, + session_id: str = Form(...), + chunk_index: int = Form(...), + chunk_data: UploadFile = File(...), +) -> ChunkUploadResponse | JSONResponse: + """단일 파일 청크를 B03 임시 폴더에 저장하고 DB에 기록한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + upload_session = await get_upload_session( + connection, + project_id=project_id, + session_id=session_id, + ) + if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + session_dir = resolve_chunk_session_dir(project_root, session_id) + chunk_path, size_bytes, chunk_hash = await save_upload_chunk( + chunk_data, + session_dir, + chunk_index, + expected_max_bytes=int(upload_session["chunk_size_bytes"]), + ) + relative_chunk_path = chunk_path.relative_to(project_root).as_posix() + completed_chunks = await upsert_upload_chunk( + connection, + session_id=session_id, + chunk_index=chunk_index, + chunk_hash=chunk_hash, + size_bytes=size_bytes, + stored_at=relative_chunk_path, + ) + return ChunkUploadResponse( + upload_session_id=session_id, + chunk_index=chunk_index, + completed_chunks=completed_chunks, + total_chunks=int(upload_session["total_chunks"]), + chunk_hash=chunk_hash, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B03 청크 업로드 실패: project_id=%s session_id=%s", + project_id, + session_id, + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, + ) + finally: + await chunk_data.close() + + +@router.post("/{project_id}/finalize", response_model=FileUploadResponse) +async def finalize_project_upload( + project_id: UUID, + payload: UploadFinalizeRequest, + session: dict[str, Any] = Depends(verify_session), +) -> FileUploadResponse | JSONResponse: + """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" + pool = get_db_pool() + final_path: Path | None = None + point_cloud_input_id: int | None = None + try: + async with pool.acquire() as connection: + upload_session = await get_upload_session( + connection, + project_id=project_id, + session_id=payload.session_id, + ) + if int(upload_session["total_chunks"]) != payload.total_chunks: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, + session_id=payload.session_id, + ) + expected_indexes = list(range(payload.total_chunks)) + if completed_indexes != expected_indexes: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, + ) + + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + descriptor = FileUploadDescriptor( + original_filename=str(upload_session["original_filename"]), + size_bytes=int(upload_session["file_size_bytes"]), + ) + final_path = merge_upload_chunks( + project_root, + descriptor, + payload.session_id, + payload.total_chunks, + ) + metadata = await asyncio.to_thread(analyze_input_metadata, final_path) + # 다음에 같은 파일이 올라오면 전송을 건너뛸 수 있도록 지문을 함께 남긴다. + fingerprint = payload.fingerprint or None + if fingerprint: + metadata = {**metadata, "fingerprint": fingerprint} + relative_path = final_path.relative_to(project_root).as_posix() + file_type = final_path.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + + await connection.begin() + try: + input_file_id = await create_input_file( + connection, + project_id=project_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=descriptor.size_bytes, + upload_by=None, + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + # 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다. + await supersede_previous_input_files( + connection, + project_id, + descriptor.original_filename, + input_file_id, + ) + await mark_upload_session_completed(connection, session_id=payload.session_id) + if payload.complete_upload: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + payload.las_free, + ) + await connection.commit() + except Exception: + await connection.rollback() + await mark_upload_session_failed(connection, session_id=payload.session_id) + raise + + remove_chunk_session(project_root, payload.session_id) + result = UploadedFileResult( + input_file_id=input_file_id, + original_filename=descriptor.original_filename, + file_type=file_type, + relative_path=relative_path, + size_bytes=descriptor.size_bytes, + metadata=metadata, + ) + stage_root = project_root / "B03_FileInput" + _write_stage_metadata(stage_root, project_id, [result]) + # 업로드 직후 안내 메일은 보내지 않는다 — 위 일반 업로드 경로와 같은 이유. + if point_cloud_input_id is not None: + _schedule_background_task( + trigger_wf1_analysis_and_email( + project_id=project_id, + input_file_id=point_cloud_input_id, + user_role=str(session["role"]), + ), + task_name=f"b04-preprocess-auto-{project_id}", + ) + return FileUploadResponse(project_id=str(project_id), files=[result]) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + if final_path is not None: + final_path.unlink(missing_ok=True) + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + if final_path is not None: + final_path.unlink(missing_ok=True) + logger.exception("B03 청크 업로드 최종 병합 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/upload-status/{session_id}", response_model=UploadStatusResponse) +async def get_project_upload_status( + project_id: UUID, + session_id: str, +) -> UploadStatusResponse | JSONResponse: + """업로드 세션의 청크 완료 상태를 조회한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + upload_session = await get_upload_session( + connection, + project_id=project_id, + session_id=session_id, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, + session_id=session_id, + ) + return UploadStatusResponse( + upload_session_id=session_id, + upload_status=str(upload_session["status"]), + original_filename=str(upload_session["original_filename"]), + file_size_bytes=int(upload_session["file_size_bytes"]), + chunk_size_bytes=int(upload_session["chunk_size_bytes"]), + total_chunks=int(upload_session["total_chunks"]), + completed_chunks=len(completed_indexes), + completed_chunk_indexes=completed_indexes, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B03 업로드 상태 조회 실패: project_id=%s session_id=%s", + project_id, + session_id, + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, + ) diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py new file mode 100644 index 00000000..2251fc9c --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -0,0 +1,296 @@ +"""B03 파일 입력 라우터 보조 — 필수 파일 판정·중복 업로드 판별·단계 기록·알림. + +라우터 본체(`B03_FileInput_Router.py`)가 700줄을 넘어 떼어낸 조각이다(2026-09-04). +엔드포인트는 두지 않는다 — 순수 보조 함수와 파일 종류 상수만 둔다. +""" + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql + +from B03_FileInput.B03_FileInput_Email import ( + send_file_upload_complete_email, +) +from B03_FileInput.B03_FileInput_Repository import ( + find_input_file_by_name, + get_project_input_readiness, + get_project_storage_relative_path, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + UploadedFileResult, +) +from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot +from common_util.common_util_json import atomic_write_json +from common_util.common_util_project_reset import purge_project_outputs +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import ( + complete_stage, + reset_stages_after_input_change, +) +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요." + +_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"}) +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) +# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile). +_ROUTE_FILE_TYPES = frozenset({"csv", "shp"}) +# shapefile은 이것들이 다 있어야 열린다. `.cpg`는 없으면 CP949로 읽으므로 필수가 아니다. +# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다. +_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"}) + + +def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: + return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) + + +def _is_point_cloud_result(result: UploadedFileResult) -> bool: + """포인트클라우드 결과인지 — 임시 보관함 안내 메일 경로에서 쓴다. + + 프로젝트 업로드 경로는 더 이상 이 판정으로 메일을 보내지 않는다 + ([[_send_upload_complete_notification]] 주석 참고). + """ + 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]: + # 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)) + if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): + missing.append("las/laz") + return missing + + +def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None: + missing = _missing_required_file_types(file_types, las_free) + if missing: + raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") + + +def _stored_fingerprint(metadata: Any) -> str | None: + """입력 파일 메타데이터에 적어 둔 지문을 꺼낸다.""" + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except (TypeError, ValueError): + return None + if not isinstance(metadata, dict): + return None + value = metadata.get("fingerprint") + return str(value) if value else None + + +async def _already_uploaded( + connection: aiomysql.Connection, + project_id: UUID, + payload: ChunkSessionCreateRequest, +) -> ChunkSessionCreateResponse | None: + """같은 이름으로 **같은 내용**이 이미 올라와 있으면 전송을 건너뛰라는 응답을 만든다. + + 1.7GB를 다 받은 뒤에 비교하면 아낄 게 없으므로, 세션을 만들기 전에 화면이 보내 준 + 지문으로 가린다. 지문이 없거나 다르면 그냥 올린다 — 애매하면 올리는 쪽이 안전하다. + """ + if not payload.fingerprint: + return None + existing = await find_input_file_by_name(connection, project_id, payload.original_filename) + if not existing or _stored_fingerprint(existing.get("metadata")) != payload.fingerprint: + return None + logger.info( + "B03 같은 파일 재업로드 — 전송 생략: project_id=%s file=%s", + project_id, + payload.original_filename, + ) + return ChunkSessionCreateResponse( + project_id=str(project_id), + upload_session_id="", + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=payload.chunk_size_bytes, + total_chunks=0, + already_uploaded=True, + ) + + +async def _complete_file_input_if_ready( + connection: aiomysql.Connection, + project_id: UUID, + las_free: bool = False, +) -> int: + file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( + connection, project_id + ) + _require_complete_file_set(file_types, las_free) + if point_cloud_input_id is None: + if not las_free: + raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") + if route_csv_input_id is None: + raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.") + # 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면 + # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. + 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) + async with connection.cursor(aiomysql.DictCursor) as cursor: + await reset_stages_after_input_change(cursor, str(project_id)) + await complete_stage(cursor, str(project_id), 0) + # LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다. + return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id) + + +def _write_stage_metadata( + stage_root: Path, + project_id: UUID, + results: list[UploadedFileResult], +) -> None: + metadata_path = stage_root / "metadata.json" + existing_files: list[dict[str, Any]] = [] + if metadata_path.exists(): + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + existing_files = list(payload.get("files") or []) + except (OSError, TypeError, ValueError): + logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) + + merged = { + str(item.get("relative_path") or item.get("original_filename")): item + for item in existing_files + } + for result in results: + dumped = result.model_dump() + merged[result.relative_path] = dumped + atomic_write_json( + metadata_path, + {"project_id": str(project_id), "files": list(merged.values())}, + ) + + +# 실행 중인 백그라운드 작업의 강한 참조. 이벤트 루프는 작업을 약한 참조로만 들고 있어, +# 여기서 붙잡지 않으면 GC가 대기 중인 작업을 통째로 회수해 WF1 분석이 조용히 사라진다 +# (업로드는 성공·stage 0은 COMPLETE인데 stage 1은 NOT_STARTED로 남는 증상). +_BACKGROUND_TASKS: set[asyncio.Task] = set() + + +def _schedule_background_task(coro: Any, *, task_name: str) -> None: + task = asyncio.create_task(coro, name=task_name) + _BACKGROUND_TASKS.add(task) + + def _log_task_failure(completed: asyncio.Task) -> None: + _BACKGROUND_TASKS.discard(completed) + try: + completed.result() + except asyncio.CancelledError: + logger.warning("백그라운드 작업 취소됨: %s", task_name) + except Exception: + logger.exception("백그라운드 작업 실패: %s", task_name) + + task.add_done_callback(_log_task_failure) + logger.info("백그라운드 작업 시작: %s", task_name) + + +async def _get_project_notification_info( + connection: aiomysql.Connection, + project_id: UUID, +) -> dict[str, Any] | None: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT + p.id, + p.name AS project_name, + u.email AS user_email, + u.name AS user_name + FROM projects p + JOIN users u ON u.id = p.user_id + WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL + """, + (str(project_id),), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + +async def _update_project_status(project_id: UUID, status: str) -> None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE projects + SET status = %s, updated_at = NOW() + WHERE id = %s AND deleted_at IS NULL + """, + (status, str(project_id)), + ) + await connection.commit() + + +async def _send_upload_complete_notification( + *, + project_id: UUID, + uploaded_file: UploadedFileResult, +) -> None: + """저장만 끝났을 때 보내는 안내. + + 프로젝트 업로드 경로에서는 호출하지 않는다 — 그 흐름은 초기 설계까지 마친 뒤 + 통합 메일 한 통으로 알린다. 프로젝트 생성 전 임시 보관함 업로드에서 쓸 예정이라 + 지워두지 않았다(2026-08-08 사용자 지시). + """ + pool = get_db_pool() + async with pool.acquire() as connection: + project_info = await _get_project_notification_info(connection, project_id) + if not project_info or not project_info.get("user_email"): + logger.warning("업로드 완료 이메일 수신자 없음: project_id=%s", project_id) + return + + await send_file_upload_complete_email( + to_email=str(project_info["user_email"]), + user_name=str(project_info.get("user_name") or "사용자"), + project_name=str(project_info.get("project_name") or project_id), + file_name=uploaded_file.original_filename, + file_size_mb=uploaded_file.size_bytes / (1024 * 1024), + metadata=uploaded_file.metadata, + ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index a190a34b..e962669b 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -13,53 +13,65 @@ from typing import Any from uuid import UUID, uuid4 import aiomysql -from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi import APIRouter, Depends, File, UploadFile from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Engine import ( - merge_upload_chunks, - remove_chunk_session, - resolve_chunk_session_dir, resolve_upload_destination, - save_upload_chunk, save_upload_stream, ) from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, get_project_storage_relative_path, - list_completed_chunk_indexes, - mark_upload_session_completed, - mark_upload_session_failed, supersede_previous_input_files, - upsert_upload_chunk, ) from B03_FileInput.B03_FileInput_Repository_Temp import ( create_temp_batch, - create_temp_upload_session, delete_temp_batch, delete_temp_batch_file, get_temp_batch, get_temp_batch_file, - get_temp_batch_file_types, - get_temp_upload_session, is_batch_required_complete, list_temp_batch_files, list_temp_batch_sessions, list_temp_batches, - mark_temp_batch_completed, - mark_temp_batch_incomplete, mark_temp_batch_linked, upsert_temp_batch_file, ) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response + +# 분리 전 이 파일에 있던 이름은 그대로 다시 내보낸다(2026-09-04). +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + create_batch_upload_session as create_batch_upload_session, +) +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + finalize_batch_upload as finalize_batch_upload, +) +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + get_batch_upload_status as get_batch_upload_status, +) +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import router as chunk_router +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + upload_batch_chunk as upload_batch_chunk, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _POINT_CLOUD_FILE_TYPES as _POINT_CLOUD_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _batch_root as _batch_root, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _iso as _iso, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _refresh_batch_status as _refresh_batch_status, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _total_chunks as _total_chunks, +) from B03_FileInput.B03_FileInput_Schema import ( - ChunkSessionCreateRequest, - ChunkSessionCreateResponse, - ChunkUploadResponse, FileUploadDescriptor, - UploadFinalizeRequest, - UploadStatusResponse, ) from B03_FileInput.B03_FileInput_Schema_Temp import ( TempBatchAttachResponse, @@ -87,7 +99,6 @@ from common_util.common_util_workflow_state import ( from config.config_db import get_db_pool from config.config_system import ( TEMP_UPLOAD_RETENTION_DAYS, - UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_FILES, ) @@ -97,33 +108,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/temp-uploads", tags=["B03 Temp Upload"]) attach_router = APIRouter(prefix="/api/projects", tags=["B03 Temp Upload"]) -_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) - - -def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: - return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) - - -def _iso(value: Any) -> str | None: - return value.isoformat() if value is not None and hasattr(value, "isoformat") else None - - -async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: - """소유권을 확인하고 묶음 폴더를 돌려준다.""" - await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) - return Path(resolve_temp_batch_path(user_id, batch_id)) - - -async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool: - """필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다.""" - file_types = await get_temp_batch_file_types(connection, batch_id=batch_id) - complete = is_batch_required_complete(file_types) - if complete: - await mark_temp_batch_completed(connection, batch_id=batch_id) - else: - # 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다. - await mark_temp_batch_incomplete(connection, batch_id=batch_id) - return complete +# 청크 업로드 엔드포인트는 파일이 700줄을 넘어 떼어냈다(2026-09-04) — 경로는 그대로다. +router.include_router(chunk_router) @router.post("", response_model=TempBatchCreateResponse) @@ -367,240 +353,6 @@ async def upload_batch_files( await upload.close() -@router.post("/{batch_id}/upload-sessions", response_model=ChunkSessionCreateResponse) -async def create_batch_upload_session( - batch_id: str, - payload: ChunkSessionCreateRequest, - session: dict[str, Any] = Depends(verify_session), -) -> ChunkSessionCreateResponse | JSONResponse: - """대용량 파일(LAS/LAZ) 청크 세션을 만든다 — 프로젝트 업로드와 같은 규칙.""" - user_id = int(session["user_id"]) - chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) - total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) - session_id = str(uuid4()) - pool = get_db_pool() - try: - async with pool.acquire() as connection: - await _batch_root(connection, batch_id=batch_id, user_id=user_id) - await create_temp_upload_session( - connection, - session_id=session_id, - batch_id=batch_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - await connection.commit() - return ChunkSessionCreateResponse( - project_id=batch_id, - upload_session_id=session_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("임시 보관함 청크 세션 생성 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, - ) - - -@router.post("/{batch_id}/chunks", response_model=ChunkUploadResponse) -async def upload_batch_chunk( - batch_id: str, - session_id: str = Form(...), - chunk_index: int = Form(...), - chunk_data: UploadFile = File(...), - session: dict[str, Any] = Depends(verify_session), -) -> ChunkUploadResponse | JSONResponse: - """청크 한 조각을 보관함 묶음 폴더에 저장한다.""" - user_id = int(session["user_id"]) - pool = get_db_pool() - try: - async with pool.acquire() as connection: - batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) - upload_session = await get_temp_upload_session( - connection, batch_id=batch_id, session_id=session_id - ) - if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): - return JSONResponse( - status_code=400, - content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, - ) - session_dir = resolve_chunk_session_dir(batch_root, session_id) - chunk_path, size_bytes, chunk_hash = await save_upload_chunk( - chunk_data, - session_dir, - chunk_index, - expected_max_bytes=int(upload_session["chunk_size_bytes"]), - ) - completed_chunks = await upsert_upload_chunk( - connection, - session_id=session_id, - chunk_index=chunk_index, - chunk_hash=chunk_hash, - size_bytes=size_bytes, - stored_at=chunk_path.relative_to(batch_root).as_posix(), - ) - return ChunkUploadResponse( - upload_session_id=session_id, - chunk_index=chunk_index, - completed_chunks=completed_chunks, - total_chunks=int(upload_session["total_chunks"]), - chunk_hash=chunk_hash, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("임시 보관함 청크 업로드 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, - ) - finally: - await chunk_data.close() - - -@router.get("/{batch_id}/upload-status/{session_id}", response_model=UploadStatusResponse) -async def get_batch_upload_status( - batch_id: str, - session_id: str, - session: dict[str, Any] = Depends(verify_session), -) -> UploadStatusResponse | JSONResponse: - """이어올리기용 — 이미 올라간 청크 번호를 돌려준다.""" - user_id = int(session["user_id"]) - pool = get_db_pool() - try: - async with pool.acquire() as connection: - await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) - upload_session = await get_temp_upload_session( - connection, batch_id=batch_id, session_id=session_id - ) - completed_indexes = await list_completed_chunk_indexes( - connection, session_id=session_id - ) - return UploadStatusResponse( - upload_session_id=session_id, - upload_status=str(upload_session["status"]), - original_filename=str(upload_session["original_filename"]), - file_size_bytes=int(upload_session["file_size_bytes"]), - chunk_size_bytes=int(upload_session["chunk_size_bytes"]), - total_chunks=int(upload_session["total_chunks"]), - completed_chunks=len(completed_indexes), - completed_chunk_indexes=completed_indexes, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except Exception: - logger.exception("임시 보관함 업로드 상태 조회 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, - ) - - -@router.post("/{batch_id}/finalize", response_model=TempFileUploadResponse) -async def finalize_batch_upload( - batch_id: str, - payload: UploadFinalizeRequest, - session: dict[str, Any] = Depends(verify_session), -) -> TempFileUploadResponse | JSONResponse: - """청크를 병합해 보관함에 저장하고, 필수 파일이 다 차면 완료로 올린다.""" - user_id = int(session["user_id"]) - pool = get_db_pool() - final_path: Path | None = None - try: - async with pool.acquire() as connection: - batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) - upload_session = await get_temp_upload_session( - connection, batch_id=batch_id, session_id=payload.session_id - ) - if int(upload_session["total_chunks"]) != payload.total_chunks: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, - ) - completed_indexes = await list_completed_chunk_indexes( - connection, session_id=payload.session_id - ) - if completed_indexes != list(range(payload.total_chunks)): - return JSONResponse( - status_code=400, - content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, - ) - - descriptor = FileUploadDescriptor( - original_filename=str(upload_session["original_filename"]), - size_bytes=int(upload_session["file_size_bytes"]), - ) - final_path = merge_upload_chunks( - batch_root, descriptor, payload.session_id, payload.total_chunks - ) - metadata = await asyncio.to_thread(analyze_input_metadata, final_path) - relative_path = final_path.relative_to(batch_root).as_posix() - file_type = final_path.suffix.lower().lstrip(".") - crs_epsg = metadata.get("epsg") - - await connection.begin() - try: - await upsert_temp_batch_file( - connection, - batch_id=batch_id, - file_type=file_type, - original_filename=descriptor.original_filename, - relative_path=relative_path, - file_size_bytes=int(upload_session["file_size_bytes"]), - crs_epsg=int(crs_epsg) if crs_epsg is not None else None, - metadata=metadata, - ) - await mark_upload_session_completed(connection, session_id=payload.session_id) - required_complete = await _refresh_batch_status(connection, batch_id=batch_id) - await connection.commit() - except Exception: - await connection.rollback() - await mark_upload_session_failed(connection, session_id=payload.session_id) - raise - - remove_chunk_session(batch_root, payload.session_id) - return TempFileUploadResponse( - batch_id=batch_id, - files=[ - TempFileUploadResult( - batch_id=batch_id, - file_type=file_type, - original_filename=descriptor.original_filename, - relative_path=relative_path, - size_bytes=int(upload_session["file_size_bytes"]), - metadata=metadata, - ) - ], - required_complete=required_complete, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except (OSError, ValueError) as exc: - if final_path is not None: - final_path.unlink(missing_ok=True) - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - if final_path is not None: - final_path.unlink(missing_ok=True) - logger.exception("임시 보관함 병합 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, - ) - - @attach_router.post( "/{project_id}/temp-uploads/{batch_id}/attach", response_model=TempBatchAttachResponse ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp_Chunks.py b/B03_FileInput/B03_FileInput_Router_Temp_Chunks.py new file mode 100644 index 00000000..20990d92 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Temp_Chunks.py @@ -0,0 +1,294 @@ +"""임시 보관함 청크 업로드 엔드포인트 — 세션 생성·조각 전송·진행 조회·마무리. + +라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 `include_router` 로 붙이므로 +여기 라우터에는 prefix 를 두지 않는다 — 두면 `/api/temp-uploads` 가 두 번 붙는다. +""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Engine import ( + merge_upload_chunks, + remove_chunk_session, + resolve_chunk_session_dir, + save_upload_chunk, +) +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + list_completed_chunk_indexes, + mark_upload_session_completed, + mark_upload_session_failed, + upsert_upload_chunk, +) +from B03_FileInput.B03_FileInput_Repository_Temp import ( + create_temp_upload_session, + get_temp_batch, + get_temp_upload_session, + upsert_temp_batch_file, +) +from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _batch_root, + _refresh_batch_status, + _total_chunks, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + ChunkUploadResponse, + FileUploadDescriptor, + UploadFinalizeRequest, + UploadStatusResponse, +) +from B03_FileInput.B03_FileInput_Schema_Temp import ( + TempFileUploadResponse, + TempFileUploadResult, +) +from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool +from config.config_system import ( + UPLOAD_CHUNK_SIZE_BYTES, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["B03 Temp Upload"]) + + +@router.post("/{batch_id}/upload-sessions", response_model=ChunkSessionCreateResponse) +async def create_batch_upload_session( + batch_id: str, + payload: ChunkSessionCreateRequest, + session: dict[str, Any] = Depends(verify_session), +) -> ChunkSessionCreateResponse | JSONResponse: + """대용량 파일(LAS/LAZ) 청크 세션을 만든다 — 프로젝트 업로드와 같은 규칙.""" + user_id = int(session["user_id"]) + chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) + total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) + session_id = str(uuid4()) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await _batch_root(connection, batch_id=batch_id, user_id=user_id) + await create_temp_upload_session( + connection, + session_id=session_id, + batch_id=batch_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + await connection.commit() + return ChunkSessionCreateResponse( + project_id=batch_id, + upload_session_id=session_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 청크 세션 생성 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/chunks", response_model=ChunkUploadResponse) +async def upload_batch_chunk( + batch_id: str, + session_id: str = Form(...), + chunk_index: int = Form(...), + chunk_data: UploadFile = File(...), + session: dict[str, Any] = Depends(verify_session), +) -> ChunkUploadResponse | JSONResponse: + """청크 한 조각을 보관함 묶음 폴더에 저장한다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=session_id + ) + if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, + ) + session_dir = resolve_chunk_session_dir(batch_root, session_id) + chunk_path, size_bytes, chunk_hash = await save_upload_chunk( + chunk_data, + session_dir, + chunk_index, + expected_max_bytes=int(upload_session["chunk_size_bytes"]), + ) + completed_chunks = await upsert_upload_chunk( + connection, + session_id=session_id, + chunk_index=chunk_index, + chunk_hash=chunk_hash, + size_bytes=size_bytes, + stored_at=chunk_path.relative_to(batch_root).as_posix(), + ) + return ChunkUploadResponse( + upload_session_id=session_id, + chunk_index=chunk_index, + completed_chunks=completed_chunks, + total_chunks=int(upload_session["total_chunks"]), + chunk_hash=chunk_hash, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 청크 업로드 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, + ) + finally: + await chunk_data.close() + + +@router.get("/{batch_id}/upload-status/{session_id}", response_model=UploadStatusResponse) +async def get_batch_upload_status( + batch_id: str, + session_id: str, + session: dict[str, Any] = Depends(verify_session), +) -> UploadStatusResponse | JSONResponse: + """이어올리기용 — 이미 올라간 청크 번호를 돌려준다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=session_id + ) + completed_indexes = await list_completed_chunk_indexes( + connection, session_id=session_id + ) + return UploadStatusResponse( + upload_session_id=session_id, + upload_status=str(upload_session["status"]), + original_filename=str(upload_session["original_filename"]), + file_size_bytes=int(upload_session["file_size_bytes"]), + chunk_size_bytes=int(upload_session["chunk_size_bytes"]), + total_chunks=int(upload_session["total_chunks"]), + completed_chunks=len(completed_indexes), + completed_chunk_indexes=completed_indexes, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except Exception: + logger.exception("임시 보관함 업로드 상태 조회 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/finalize", response_model=TempFileUploadResponse) +async def finalize_batch_upload( + batch_id: str, + payload: UploadFinalizeRequest, + session: dict[str, Any] = Depends(verify_session), +) -> TempFileUploadResponse | JSONResponse: + """청크를 병합해 보관함에 저장하고, 필수 파일이 다 차면 완료로 올린다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + final_path: Path | None = None + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=payload.session_id + ) + if int(upload_session["total_chunks"]) != payload.total_chunks: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, session_id=payload.session_id + ) + if completed_indexes != list(range(payload.total_chunks)): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, + ) + + descriptor = FileUploadDescriptor( + original_filename=str(upload_session["original_filename"]), + size_bytes=int(upload_session["file_size_bytes"]), + ) + final_path = merge_upload_chunks( + batch_root, descriptor, payload.session_id, payload.total_chunks + ) + metadata = await asyncio.to_thread(analyze_input_metadata, final_path) + relative_path = final_path.relative_to(batch_root).as_posix() + file_type = final_path.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + + await connection.begin() + try: + await upsert_temp_batch_file( + connection, + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=int(upload_session["file_size_bytes"]), + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + await mark_upload_session_completed(connection, session_id=payload.session_id) + required_complete = await _refresh_batch_status(connection, batch_id=batch_id) + await connection.commit() + except Exception: + await connection.rollback() + await mark_upload_session_failed(connection, session_id=payload.session_id) + raise + + remove_chunk_session(batch_root, payload.session_id) + return TempFileUploadResponse( + batch_id=batch_id, + files=[ + TempFileUploadResult( + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + size_bytes=int(upload_session["file_size_bytes"]), + metadata=metadata, + ) + ], + required_complete=required_complete, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except (OSError, ValueError) as exc: + if final_path is not None: + final_path.unlink(missing_ok=True) + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + if final_path is not None: + final_path.unlink(missing_ok=True) + logger.exception("임시 보관함 병합 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, + ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp_Support.py b/B03_FileInput/B03_FileInput_Router_Temp_Support.py new file mode 100644 index 00000000..30e7477a --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Temp_Support.py @@ -0,0 +1,46 @@ +"""임시 보관함 라우터 보조 — 조각 수 계산·묶음 폴더 확인·상태 갱신. + +라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체와 청크 모듈이 함께 쓰는 것만 둔다. +""" + +from pathlib import Path +from typing import Any + +from B03_FileInput.B03_FileInput_Repository_Temp import ( + get_temp_batch, + get_temp_batch_file_types, + is_batch_required_complete, + mark_temp_batch_completed, + mark_temp_batch_incomplete, +) +from common_util.common_util_storage import ( + resolve_temp_batch_path, +) + +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + + +def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: + return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) + + +def _iso(value: Any) -> str | None: + return value.isoformat() if value is not None and hasattr(value, "isoformat") else None + + +async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: + """소유권을 확인하고 묶음 폴더를 돌려준다.""" + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + return Path(resolve_temp_batch_path(user_id, batch_id)) + + +async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool: + """필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다.""" + file_types = await get_temp_batch_file_types(connection, batch_id=batch_id) + complete = is_batch_required_complete(file_types) + if complete: + await mark_temp_batch_completed(connection, batch_id=batch_id) + else: + # 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다. + await mark_temp_batch_incomplete(connection, batch_id=batch_id) + return complete diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 18d24b7a..5211862d 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -131,6 +131,9 @@ class UploadOverviewFile(BaseModel): # PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다 # (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31). relative_path: str | None = None + # 업로드 분석기가 낸 메타데이터 — 카드 미리보기(범위 사각형·노선 선)를 그리는 재료다 + # (2026-09-04). 재접속해도 같은 그림이 서도록 서버 정본을 그대로 실어 보낸다. + metadata: dict[str, Any] | None = None class UploadOverviewSession(BaseModel): diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index de3a99b7..6e8dfec7 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -1,7 +1,7 @@ """B03 업로드 이후 자동 설계 체인 — WF1 확정 다음을 잇는다. WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어도 서버가 이어서 -① B05 기본 경로 계산·확정(계획노선 CSV 기반) ② B06 기본 횡단 설계 확정까지 +① B05 기본 경로 계산·확정(계획노선 정본 기반) ② B06 기본 횡단 설계 확정까지 기본값으로 진행해 영구저장소에 남긴다(2026-08-04 사용자 확정). 이후 사용자가 대시보드에서 B05/B06에 들어오면 저장본을 바로 로딩해 검토·수정만 하면 된다. @@ -16,6 +16,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어 """ import logging +import time from pathlib import Path from typing import Any from uuid import UUID @@ -25,17 +26,32 @@ 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 + project_root: Path, + surface: dict[str, Any] | None = None, + route_range: tuple[float | None, float | None] | None = None, ) -> list[dict[str, float]] | None: """설계용 계획노선을 프로젝트 좌표계(m) 점 목록으로 돌려준다. 없으면 None. 읽기·좌표계 변환·트림·조밀화는 `load_design_route()` 한 곳에서 한다 — 배수유역·유입도 - 같은 함수를 쓰므로 여기만 트림되는 일이 없다. + 같은 함수를 쓰므로 여기만 트림되는 일이 없다. 사용자가 정한 사용 범위(`route_range`)는 + 서피스 트림보다 먼저 적용된다 (2026-09-04 사용자 지시). """ from common_util.common_util_route_geometry import load_design_route - planned = load_design_route(project_root, surface) + planned = load_design_route(project_root, surface, route_range) if planned is None: if surface: logger.warning( @@ -67,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로 부른다. @@ -79,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): @@ -90,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: @@ -107,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, @@ -139,10 +162,16 @@ async def run_auto_design_chain( from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest from B06_Section.B06_Section_Router_Confirm import confirm_sections from common_util.common_util_initial_snapshot import ( + clear_design_failed, clear_designing, + mark_design_failed, 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 @@ -162,28 +191,64 @@ async def run_auto_design_chain( ) return None - # 2) 계획노선 CSV → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. + # 2) 계획노선 정본 → BP/EP/경유점. 없으면 자동 경로를 세울 근거가 없다. project_root = Path(resolve_stored_project_path(stored_path)) # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). mark_designing(project_root) + # 옛 실패 마커는 여기서 지운다 — 이번 체인의 결과로 다시 판정한다. + clear_design_failed(project_root) # WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을 # 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이 # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. async with pool.acquire() as connection: defaults = await get_surface_confirmation_params(connection, str(project_id)) + # 사용자가 B02·B01 에서 정한 계획노선 사용 범위 (2026-09-04 사용자 지시). + # 비어 있으면 전 구간 — 지금까지와 같다. + 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() + route_range = (range_row[0], range_row[1]) if range_row else None - points = _planned_route_points_in_project_crs(project_root, defaults) + points = _planned_route_points_in_project_crs(project_root, defaults, route_range) if not points: - logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) + logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id) + 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=[ @@ -198,6 +263,10 @@ async def run_auto_design_chain( project_id, solve_result.status_code, ) + mark_design_failed( + project_root, + f"B05 초기 노선 계산 실패 (status={solve_result.status_code}).", + ) return None route_id = int(solve_result.route_id) logger.info( @@ -216,6 +285,10 @@ async def run_auto_design_chain( project_id, confirm_result.status_code, ) + mark_design_failed( + project_root, + f"B05 초기 노선 확정 실패 (status={confirm_result.status_code}).", + ) return None # 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출. @@ -234,6 +307,10 @@ async def run_auto_design_chain( route_id, sections_result.status_code, ) + mark_design_failed( + project_root, + f"B06 초기 횡단 확정 실패 (status={sections_result.status_code}).", + ) return None logger.info( "자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s", @@ -241,33 +318,82 @@ async def run_auto_design_chain( route_id, ) + # 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다 + # (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가 + # 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다. + try: + from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor + + await prebuild_corridor(project_id, route_id, project_root) + 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 사용자 확정). try: async with pool.acquire() as connection: - await save_initial_snapshot(connection, project_root, route_id) - except Exception: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 + await save_initial_snapshot(connection, project_root, route_id, points) + except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) + mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), "cross_section_count": solve_result.cross_section_count, } - except Exception: + except Exception as exc: # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. logger.exception("자동 설계 체인 실패: project_id=%s", project_id) + if project_root is not None: + mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}") finally: # 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다. if project_root is not None: 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 사용자 확정) 그 값을 @@ -279,7 +405,7 @@ async def run_redesign_chain( 새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정 측점은 확정 시 기본값으로 채워진다. - 경로가 아예 없으면 신규 자동 체인(계획노선 CSV 기본값)으로 되돌아간다. + 경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다. """ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route @@ -288,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 ( @@ -303,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) @@ -341,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( @@ -349,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 [], @@ -361,7 +494,7 @@ async def run_redesign_chain( long_sample_interval_m=params.get("long_sample_interval_m"), **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, paved=bool(options.get("paved", False)), - terrain_type=str(options.get("terrain_type") or "normal"), + terrain_type=str(options.get("terrain_type") or "special"), main_direction=str(options.get("main_direction") or "auto"), min_curve_radius_m=options.get("min_curve_radius_m"), max_uphill_grade=options.get("max_uphill_grade"), @@ -379,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, @@ -393,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로 남겨 사용자 재검토를 받는다. @@ -430,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"]) @@ -450,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, @@ -458,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 125be8fd..053b97ac 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -11,8 +11,10 @@ import aiomysql from B03_FileInput.B03_FileInput_Email import ( send_analysis_error_email, send_initial_analysis_complete_email, + send_initial_design_failed_email, ) from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_initial_snapshot import is_design_failed, read_design_failure from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import surface_confirmation_defaults from common_util.common_util_workflow_state import fail_stage, start_stage @@ -77,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"} @@ -116,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, @@ -210,14 +219,25 @@ async def trigger_wf1_analysis_and_email( # 알림 메일은 여기 한 통뿐이다 — 업로드 직후와 분석 직후로 나눠 두 통을 보내던 것을 # 초기 설계(B04~B06)까지 마친 시점의 통합 메일로 합쳤다(2026-08-08 사용자 지시). + # 체인이 깨졌으면 완료 메일 대신 관리자 문의 안내를 보낸다 — 부분 결과는 분석 + # 안 됨과 다르지 않다(2026-09-02 사용자 확정). + design_failure = read_design_failure(project_root) if is_design_failed(project_root) else "" if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"): - await send_initial_analysis_complete_email( - project_id=project_id, - project_name=str(project_info.get("project_name") or project_id), - to_email=str(project_info["user_email"]), - analysis_result=analysis_result, - design_summary=design_summary, - ) + if design_failure: + await send_initial_design_failed_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + reason=design_failure, + ) + else: + await send_initial_analysis_complete_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + analysis_result=analysis_result, + design_summary=design_summary, + ) except Exception as exc: logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B03_FileInput/B03_FileInput_UI_Guide.ts b/B03_FileInput/B03_FileInput_UI_Guide.ts new file mode 100644 index 00000000..f0e40830 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Guide.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * B03_FileInput_UI_Guide.ts + * 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시). + * + * 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어 + * 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와 + * 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로 + * 옮기고, 본문은 고르는 자리만 남긴다. + * + * 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시): + * 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에 + * `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다. + * ========================================================================== */ + +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */ +function section( + titleKey: keyof typeof ui_locales, + itemKeys: (keyof typeof ui_locales)[], +): HTMLElement { + const group = document.createElement("section"); + group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section"; + + const title = document.createElement("h3"); + title.className = "b03-file__guide-legend ui-collapsible__title"; + title.textContent = L(titleKey); + + const list = document.createElement("ul"); + list.className = "b03-file__guide-list"; + for (const key of itemKeys) { + const item = document.createElement("li"); + item.textContent = L(key); + list.append(item); + } + + group.append(title, list); + return group; +} + +/** + * 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것. + * + * `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라 + * 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다 + * (2026-09-03 사용자 지시). + */ +export function createInputGuide(statusNote?: HTMLElement): HTMLElement { + const guide = document.createElement("div"); + guide.className = "b03-file__form"; + const howTo = section("B03_Guide_How_Title", [ + "B03_Guide_How_Drop", + "B03_Guide_How_Card", + "B03_Guide_How_Temp", + ]); + if (statusNote) howTo.append(statusNote); + guide.append( + section("B03_Guide_Files_Title", [ + "B03_Guide_Files_Route", + "B03_Guide_Files_Terrain", + "B03_Guide_Files_Optional", + ]), + howTo, + section("B03_Guide_Notes_Title", [ + "B03_Guide_Notes_Replace", + "B03_Guide_Notes_Crs", + "B03_Guide_Notes_LasFree", + ]), + ); + attachCollapsible(guide); + return guide; +} diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 9ed8384c..d4d4d6e2 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -3,19 +3,15 @@ import { ROUTES, UPLOAD_ALLOWED_EXT, UPLOAD_MAX_FILES, - UPLOAD_MAX_MB, } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createButton, createTag, showToast } from "@ui/ui_template_elements"; +import { createButton, createTag } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; -import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch"; -import { clearPreloadMark } from "../A00_Common/b_asset_cache"; +import { fetchUploadOverview } from "./B03_FileInput_Api_Fetch"; import { navigateTo } from "../A00_Common/router"; -import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; -import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { createInputGuide } from "./B03_FileInput_UI_Guide"; import { createTempPicker } from "./B03_FileInput_UI_TempPicker"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { @@ -24,28 +20,31 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState } from "./B03_FileInput_State"; +import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow"; import { - confirmReplaceUpload, - isInitialPipelineRunning, - pollInitialPipeline, - renderUploadResults, - uploadOneFile, -} from "./B03_FileInput_UI_Upload"; + isSlotRequired, + slotForOverviewFile, + terrainCoverage, + validateFileForSlot, + validateSlots, +} from "./B03_FileInput_UI_Page_Rules"; +import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview"; +import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, formatBytes, formatEta, - getExtension, initializeSlots, makeSessionKey, planSlotAssignments, + pushExtraFile, ROUTE_SLOTS, SHAPEFILE_DEPENDENT_SLOTS, slotConfigs, + slotFileLabel, TERRAIN_SLOTS, type FileSlot, type FileSlotState, - type StoredUploadSession, type UploadStatus, } from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; @@ -62,8 +61,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다. const tempPicker = createTempPicker(() => updateUploadButton()); const cardMap = new Map(); - const resultList = document.createElement("ul"); - resultList.className = "b03-file__results"; let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; @@ -76,12 +73,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1" : false; - function clearDerivedCaches(projectId: string): void { - clearRouteLatestCache(projectId); - invalidateSectionDetail(projectId); - clearPreloadMark(); - } - const subtitle = document.createElement("p"); subtitle.className = "b03-file__subtitle"; subtitle.textContent = L("B03_File_Subtitle"); @@ -97,9 +88,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { dropzone.tabIndex = 0; const dropzoneLabel = document.createElement("strong"); dropzoneLabel.textContent = L("B03_File_Select_Label"); - const dropzoneHint = document.createElement("span"); - dropzoneHint.textContent = L("B03_File_Select_Hint"); - dropzone.append(dropzoneLabel, dropzoneHint, fileInput); + // 설명은 좌측 안내 패널 몫이다(2026-09-03 사용자 지시) — 여기는 자리 이름만 남긴다. + dropzone.append(dropzoneLabel, fileInput); const pageError = document.createElement("p"); pageError.className = "b03-file__error"; @@ -182,9 +172,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; return; } - const validation = validateSlots(); + const validation = validateSlots(slots, selectedStates(), activeProjectId, lasFreeDesign); uploadButton.disabled = validation !== null; - pageError.textContent = validation ?? ""; + // 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에 + // 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다. + pageError.textContent = + validation === null || validation === L("B03_File_Error_Required") ? "" : validation; } /** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */ @@ -192,7 +185,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const extLabel = state.extensions.join(", "); const target = card.querySelector(".b03-file__card-ext"); if (!target) return; - target.textContent = isSlotRequired(state) + target.textContent = isSlotRequired(state, slots, lasFreeDesign) ? extLabel : `${extLabel} · ${L("B03_File_Card_Optional")}`; } @@ -214,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) @@ -242,9 +235,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (state.error) setCardState(slot, "failed"); else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty"); else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); + + const preview = card.querySelector(".b03-file__preview"); + if (preview) { + const terrain = terrainCoverage(slots); + renderSlotPreview(preview, { + metadata: state.serverUploaded?.metadata, + // 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시). + completed: Boolean(state.serverUploaded), + terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null, + terrainCrs: terrain.crs, + crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata), + }); + } updateUploadButton(); } + /** + * 지형 자료(라이다·GeoTIFF)가 덮는 범위를 하나로 합친다 — 계획노선이 그 안에 + * 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라 + * 카드에서 바로 보이게 한다(2026-09-04). + */ function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; @@ -253,14 +264,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { renderSlot(slot); } - function validateFileForSlot(file: File, state: FileSlotState): string | null { - const extension = getExtension(file.name); - const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; - if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); - if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); - return null; - } - async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { const state = targetSlot ? slots.get(targetSlot) : undefined; if (!state) { @@ -272,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 사용자 지시). 이어올리기로 @@ -302,28 +310,31 @@ export async function renderB03FileInput(root: HTMLElement): Promise { function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void { if (selection.length === 0) return; - // LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) — - // 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다. - const pointCloudExtensions = slots.get("las_laz")?.extensions ?? []; - const files = lasFreeDesign - ? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name))) - : selection; - const blocked = files.length !== selection.length; - if (blocked && files.length === 0) { - pageError.textContent = L("B03_File_Error_LasFreeBlocked"); - return; - } // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). // 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이 // 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시). const routeFile = slots.get("csv")?.file?.name; - const assignments = planSlotAssignments( - files, + const planned = planSlotAssignments( + selection, slotConfigs(), routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined, ); + // LAS 없이 설계를 켜면 **지형 자료를 통째로** 받지 않는다(2026-09-03 사용자 지시 — + // 종전에는 포인트클라우드만 걸렀다). 확장자가 아니라 **배정된 카드**로 거른다: `.prj`는 + // 노선·지형이 같은 확장자라 확장자로 거르면 노선 좌표계까지 함께 떨어진다. + const assignments = lasFreeDesign + ? planned.filter((item) => { + const slot = targetSlot ?? item.slot; + return slot === undefined || !TERRAIN_SLOTS.includes(slot); + }) + : planned; + const blocked = assignments.length !== planned.length; + if (assignments.length === 0) { + pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : ""; + return; + } const occupied = new Set(selectedStates().map((state) => state.slot)); for (const item of assignments) { const slot = targetSlot ?? item.slot; @@ -354,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; @@ -365,47 +377,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } /** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */ - function routeIsShapefile(): boolean { - const state = slots.get("csv"); - const name = state?.file?.name ?? state?.serverUploaded?.name; - return getExtension(name ?? "") === ".shp"; - } - - /** - * 이 카드가 지금 필수인가. - * - * shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 — - * CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31). - */ - function isSlotRequired(state: FileSlotState): boolean { - if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(); - if (state.slot === "las_laz") return !lasFreeDesign; - return state.isRequired; - } - - function validateSlots(): string | null { - if (!activeProjectId) return L("B03_File_Error_Project"); - const selected = selectedStates(); - if (selected.length === 0) return L("B03_File_Error_Required"); - if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); - // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — - // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). - const missingRequired = Array.from(slots.values()).some( - (state) => isSlotRequired(state) && !state.file && !state.serverUploaded, - ); - if (missingRequired) return L("B03_File_Error_RequiredSlots"); - if (!lasFreeDesign) { - const lasState = slots.get("las_laz"); - if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); - } - for (const state of selected) { - if (state.error) return state.error; - const validation = validateFileForSlot(state.file!, state); - if (validation) return validation; - } - return null; - } - /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. @@ -421,24 +392,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // id 오름차순이므로 마지막 것이 남는다). for (const state of slots.values()) state.serverUploaded = undefined; for (const file of overview.files) { - const extension = `.${file.file_type.toLowerCase()}`; - // PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 - // 저장 경로로 가린다(2026-08-31). - const inRouteSet = (file.relative_path ?? "").includes("/input/shp/"); - const slot: FileSlot | undefined = - extension === ".prj" - ? inRouteSet - ? "route_prj" - : "prj" - : Array.from(slots.values()).find( - (candidate) => - candidate.slot !== "route_prj" && candidate.extensions.includes(extension), - )?.slot; + const slot = slotForOverviewFile(file, slots); const state = slot ? slots.get(slot) : undefined; if (state) { state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb, + metadata: file.metadata ?? undefined, }; } } @@ -525,186 +485,28 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return group; } - async function detectPausedUploads(): Promise { - activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - resumeBanner.replaceChildren(); - resumeBanner.classList.remove("is-visible"); - if (!activeProjectId) return; - - for (const state of selectedStates()) { - const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!)); - if (!stored) continue; - const session = JSON.parse(stored) as StoredUploadSession; - state.uploadSessionId = session.uploadSessionId; - state.progressBytes = session.completedChunks * session.chunkSizeBytes; - renderSlot(state.slot); - const text = document.createElement("span"); - text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; - const resume = createButton({ - label: L("B03_File_Resume_Button"), - variant: "ghost", - onClick: () => void startChunkedUpload([state]), - }); - const fresh = createButton({ - label: L("B03_File_New_Button"), - variant: "ghost", - onClick: () => { - localStorage.removeItem(session.key); - state.uploadSessionId = undefined; - state.progressBytes = 0; - renderSlot(state.slot); - resumeBanner.replaceChildren(); - resumeBanner.classList.remove("is-visible"); - }, - }); - resumeBanner.append(text, resume, fresh); - resumeBanner.classList.add("is-visible"); - break; - } - } - - /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ - const pollAnalysis = (projectId: string): Promise => - pollInitialPipeline(projectId, (message) => { - pageError.textContent = message; - showToast(message, "warning"); - }); - - /** - * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. - * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 - * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. - */ - async function attachSelectedTempBatch(): Promise { - const batch = tempPicker.selected(); - if (!batch) return; - activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - if (!activeProjectId) { - pageError.textContent = L("B03_File_Error_Project"); - return; - } - pageError.textContent = ""; - try { - const result = await attachTempBatch(activeProjectId, batch.batch_id); - clearDerivedCaches(activeProjectId); - tempPicker.clear(); - showToast(L("B03_Temp_Attach_Success"), "success"); - await applyUploadOverview(); - if (!result.analysis_started) { - showToast(L("B03_Temp_Attach_NoAnalysis"), "warning"); - return; - } - showToast(L("B03_File_Analysis_InProgress"), "info"); - const analysisComplete = await pollAnalysis(activeProjectId); - if (analysisComplete) { - navigateTo(completionRoute); - } else { - showToast(L("B03_File_Analysis_StillRunning"), "warning"); - } - } catch (error) { - const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); - pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; - showToast(L("B03_Temp_Attach_Failed"), "error"); - } - } - - /** - * 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다. - * 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다. - */ - async function relockWhileInitialPipelineRuns(): Promise { - if (!activeProjectId || isUploading) return; - try { - const state = await fetchWorkflowState(activeProjectId); - if (!isInitialPipelineRunning(state)) return; - } catch { - return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다. - } - setUploading(true); - showToast(L("B03_File_Analysis_InProgress"), "info"); - try { - const done = await pollAnalysis(activeProjectId); - if (done) showToast(L("B03_File_Upload_Success"), "success"); - } finally { - setUploading(false); - void applyUploadOverview(); - } - } - - async function startChunkedUpload(targetStates = selectedStates()): Promise { - if (isUploading) return; - // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. - if (tempPicker.selected()) { - setUploading(true); - try { - await attachSelectedTempBatch(); - } finally { - setUploading(false); - } - return; - } - activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - const validation = validateSlots(); - if (validation) { - pageError.textContent = validation; - return; - } - - pageError.textContent = ""; - setUploading(true); - const uploaded: UploadedFileResult[] = []; - try { - for (let index = 0; index < targetStates.length; index += 1) { - const state = targetStates[index]; - uploaded.push( - ...(await uploadOneFile( - activeProjectId, - state, - index === targetStates.length - 1, - () => renderSlot(state.slot), - lasFreeDesign, - )), - ); - } - clearDerivedCaches(activeProjectId); - renderUploadResults(resultList, uploaded); - showToast(L("B03_File_Upload_Success"), "success"); - - showToast(L("B03_File_Analysis_InProgress"), "info"); - const analysisComplete = await pollAnalysis(activeProjectId); - - if (analysisComplete) { - navigateTo(completionRoute); - } else { - showToast(L("B03_File_Analysis_StillRunning"), "warning"); - } - } catch (error) { - const failed = targetStates.find((state) => state.uploadStatus === "uploading"); - const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); - if (failed) showErrorMessage(failed.slot, detail); - pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; - showToast(L("B03_File_Upload_Failed"), "error"); - } finally { - setUploading(false); - } - } - - async function registerB03ServiceWorker(): Promise { - if (!("serviceWorker" in navigator)) { - showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); - return; - } - try { - const registration = await navigator.serviceWorker.register( - new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url), - { type: "module" }, - ); - registration.active?.postMessage({ type: "B03_SW_PING" }); - showToast(L("B03_File_ServiceWorker_Ready"), "info"); - } catch { - showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); - } - } + // 업로드 흐름(중단 세션·보관함 이관·청크 업로드·재잠금·서비스워커)은 파일이 + // 700줄을 넘어 떼어냈다(2026-09-04). 화면 상태는 아래 창구로만 넘긴다. + const flow = createUploadFlow({ + projectId: () => activeProjectId, + setProjectId: (value) => { + activeProjectId = value; + }, + slots, + selectedStates, + lasFreeDesign: () => lasFreeDesign, + isUploading: () => isUploading, + setUploading, + renderSlot, + showErrorMessage, + applyUploadOverview, + tempPicker, + pageError, + resumeBanner, + completionRoute, + }); + const { detectPausedUploads, pollAnalysis, relockWhileInitialPipelineRuns, startChunkedUpload } = + flow; function onB03_File_Select_Change(): void { onFileSelected(fileInput.files ? Array.from(fileInput.files) : []); @@ -736,28 +538,25 @@ export async function renderB03FileInput(root: HTMLElement): Promise { disabled: true, }); + // 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함에서 불러오기] · [파일 업로드] + // (2026-09-03 사용자 지시). 셋이 한 동작의 앞뒤라 흩어 놓을 이유가 없다. + const pickRow = document.createElement("div"); + pickRow.className = "b03-file__pick-row"; + pickRow.append(dropzone, tempPicker.root, uploadButton); + const uploadControlPanel = document.createElement("div"); uploadControlPanel.className = "b03-file__control-panel"; - uploadControlPanel.append( - subtitle, - overviewBanner, - dropzone, - // 대시보드 임시 보관함에서 자료를 끌어오는 자리 — 업로드 컨테이너 안에 둔다. - tempPicker.root, - resumeBanner, - pageError, - uploadButton, - resultList, - ); + uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError); // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). // 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지 // 화면만 보고 안다(2026-08-31 사용자 지시). + // 안내 문구 없음 — CSV 관련 설명이 붙어 있었으나 CSV는 사용자가 넣는 자료가 아니다 + // (2026-09-03 사용자 지시: 내부 계산 파일). const routeGroup = createCardGroup( L("B03_File_Group_Route"), ROUTE_SLOTS, "b03-file__group--route", - L("B03_File_Group_Route_Hint"), ); const terrainGroup = createCardGroup( L("B03_File_Group_Terrain"), @@ -777,18 +576,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise { lasFreeRow.append(lasFreeCheck, lasFreeText); function applyLasFreeState(): void { lasFreeCheck.checked = lasFreeDesign; - const card = cardMap.get("las_laz"); - card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); - // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. - card - ?.querySelectorAll( - ".b03-file__card-select, .b03-file__slot-input", - ) - .forEach((element) => { - element.disabled = lasFreeDesign; - }); - // 켜기 전에 이미 골라 둔 LAS는 내린다 — 켠 채로 남아 올라가는 사고를 막는다. - if (lasFreeDesign && slots.get("las_laz")?.file) removeFile("las_laz"); + terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign); + for (const slot of TERRAIN_SLOTS) { + const card = cardMap.get(slot); + card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); + // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. + card + ?.querySelectorAll( + ".b03-file__card-select, .b03-file__card-remove, .b03-file__slot-input", + ) + .forEach((element) => { + element.disabled = lasFreeDesign; + }); + // 켜기 전에 골라 둔 로컬 파일은 내린다 — 켠 채로 남아 올라가는 사고를 막는다. + // 이미 서버에 올라간 자료(재입력)는 그대로 두고 조작만 잠근다(2026-09-03 사용자 지시). + if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot); + } } lasFreeCheck.addEventListener("change", () => { lasFreeDesign = lasFreeCheck.checked; @@ -797,10 +600,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } applyLasFreeState(); pageError.textContent = ""; + // 이 토글이 LAS 카드의 필수 여부를 바꾼다 — 버튼 판정을 다시 돌리지 않으면 파일을 다 + // 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고 + // 토글을 나중에 켠 순서에서 재현). + updateUploadButton(); }); - // LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다. - terrainGroup.append(lasFreeRow); + // LAS 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지 + // 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다. + uploadControlPanel.insertBefore(lasFreeRow, resumeBanner); const routePanel = document.createElement("div"); routePanel.className = "b03-file__control-panel b03-file__cards-container-panel"; @@ -838,10 +646,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise { content: [uploadControlPanel, cardsContainer], }); layout.content.classList.add("b03-file__main-layout"); + // 고르는 방법·필요한 파일 안내는 좌측 패널로 뺐다 — 본문은 고르는 자리만 남긴다 + // (2026-09-03 사용자 지시). 자리·여닫기는 B04·B05 좌측 패널과 같은 공용 오버레이다. const overlays = createWorkflowOverlays({ title: L("B03_File_Title"), progressContent, - showTitlePanel: false, + optionsContent: createInputGuide(overviewBanner), + // 공용 워크플로 레이아웃(B04·B05)이 하는 일을 여기서도 한다 — 패널이 열리면 본문을 + // 그만큼 밀어내지 않으면 안내가 카드 위를 덮는다. + onOptionsOpenChange: (isOpen) => layout.root.classList.toggle("is-options-open", isOpen), }); layout.root.append(overlays.root); pageRoot = layout.root; @@ -850,7 +663,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { for (const slot of slots.keys()) renderSlot(slot); applyLasFreeState(); void relockWhileInitialPipelineRuns(); - void registerB03ServiceWorker(); + void flow.registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts new file mode 100644 index 00000000..bc8c3104 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -0,0 +1,272 @@ +/* ============================================================================= + * B03_FileInput_UI_Page_Flow.ts + * 파일 입력 화면의 업로드 흐름 — 중단 세션 이어올리기, 보관함 자료 옮기기, + * 청크 업로드 시작, 초기 계산 중 재잠금, 서비스워커 등록. + * + * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * 화면이 들고 있는 상태는 `ctx` 로 받아 쓰기만 한다 — 동작·순서는 종전과 같다. + * ========================================================================== */ + +import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { navigateTo } from "../A00_Common/router"; +import { clearPreloadMark } from "../A00_Common/b_asset_cache"; +import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; +import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; +import { validateSlots } from "./B03_FileInput_UI_Page_Rules"; +import { + isInitialPipelineRunning, + pollInitialPipeline, + uploadOneFile, +} from "./B03_FileInput_UI_Upload"; +import { + makeSessionKey, + type FileSlot, + type FileSlotState, + type StoredUploadSession, +} from "./B03_FileInput_UI_Support"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 화면이 들고 있는 상태·조작을 흐름 쪽에 넘겨 주는 창구. */ +export interface UploadFlowContext { + projectId: () => string; + setProjectId: (value: string) => void; + slots: Map; + selectedStates: () => FileSlotState[]; + lasFreeDesign: () => boolean; + isUploading: () => boolean; + setUploading: (busy: boolean) => void; + renderSlot: (slot: FileSlot) => void; + showErrorMessage: (slot: FileSlot, error: string) => void; + applyUploadOverview: () => Promise; + tempPicker: { selected: () => { batch_id: string } | null; clear: () => void }; + pageError: HTMLElement; + resumeBanner: HTMLElement; + completionRoute: RoutePath; +} + +export interface UploadFlowHandle { + /** 분석 대기 — 재접속 복원 표시에서도 쓴다. */ + pollAnalysis: (projectId: string) => Promise; + detectPausedUploads: () => Promise; + relockWhileInitialPipelineRuns: () => Promise; + startChunkedUpload: (targetStates?: FileSlotState[]) => Promise; + registerB03ServiceWorker: () => Promise; +} + +/** 새 자료가 들어오면 이 프로젝트로 만들어 둔 파생 캐시를 버린다. */ +function clearDerivedCaches(projectId: string): void { + clearRouteLatestCache(projectId); + invalidateSectionDetail(projectId); + clearPreloadMark(); +} + +export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { + async function detectPausedUploads(): Promise { + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + ctx.resumeBanner.replaceChildren(); + ctx.resumeBanner.classList.remove("is-visible"); + if (!ctx.projectId()) return; + + for (const state of ctx.selectedStates()) { + const stored = localStorage.getItem(makeSessionKey(ctx.projectId(), state.file!)); + if (!stored) continue; + const session = JSON.parse(stored) as StoredUploadSession; + state.uploadSessionId = session.uploadSessionId; + state.progressBytes = session.completedChunks * session.chunkSizeBytes; + ctx.renderSlot(state.slot); + const text = document.createElement("span"); + text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; + const resume = createButton({ + label: L("B03_File_Resume_Button"), + variant: "ghost", + onClick: () => void startChunkedUpload([state]), + }); + const fresh = createButton({ + label: L("B03_File_New_Button"), + variant: "ghost", + onClick: () => { + localStorage.removeItem(session.key); + state.uploadSessionId = undefined; + state.progressBytes = 0; + ctx.renderSlot(state.slot); + ctx.resumeBanner.replaceChildren(); + ctx.resumeBanner.classList.remove("is-visible"); + }, + }); + ctx.resumeBanner.append(text, resume, fresh); + ctx.resumeBanner.classList.add("is-visible"); + break; + } + } + + /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ + const pollAnalysis = (projectId: string): Promise => + pollInitialPipeline(projectId, (message: string) => { + ctx.pageError.textContent = message; + showToast(message, "warning"); + }); + + /** + * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. + * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 + * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. + */ + async function attachSelectedTempBatch(): Promise { + const batch = ctx.tempPicker.selected(); + if (!batch) return; + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + if (!ctx.projectId()) { + ctx.pageError.textContent = L("B03_File_Error_Project"); + return; + } + ctx.pageError.textContent = ""; + try { + const result = await attachTempBatch(ctx.projectId(), batch.batch_id); + clearDerivedCaches(ctx.projectId()); + ctx.tempPicker.clear(); + showToast(L("B03_Temp_Attach_Success"), "success"); + await ctx.applyUploadOverview(); + if (!result.analysis_started) { + showToast(L("B03_Temp_Attach_NoAnalysis"), "warning"); + return; + } + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollAnalysis(ctx.projectId()); + if (analysisComplete) { + navigateTo(ctx.completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); + ctx.pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; + showToast(L("B03_Temp_Attach_Failed"), "error"); + } + } + + /** + * 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다. + * 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다. + */ + async function relockWhileInitialPipelineRuns(): Promise { + if (!ctx.projectId() || ctx.isUploading()) return; + try { + const state = await fetchWorkflowState(ctx.projectId()); + if (!isInitialPipelineRunning(state)) return; + } catch { + return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다. + } + ctx.setUploading(true); + showToast(L("B03_File_Analysis_InProgress"), "info"); + try { + const done = await pollAnalysis(ctx.projectId()); + if (done) showToast(L("B03_File_Upload_Success"), "success"); + } finally { + ctx.setUploading(false); + void ctx.applyUploadOverview(); + } + } + + async function startChunkedUpload(targetStates = ctx.selectedStates()): Promise { + if (ctx.isUploading()) return; + // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. + if (ctx.tempPicker.selected()) { + ctx.setUploading(true); + try { + await attachSelectedTempBatch(); + } finally { + ctx.setUploading(false); + } + return; + } + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + const validation = validateSlots( + ctx.slots, + ctx.selectedStates(), + ctx.projectId(), + ctx.lasFreeDesign(), + ); + if (validation) { + ctx.pageError.textContent = validation; + return; + } + + ctx.pageError.textContent = ""; + ctx.setUploading(true); + try { + // 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다. + 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 === jobs.length - 1, + () => ctx.renderSlot(state.slot), + ctx.lasFreeDesign(), + file, + ); + } + clearDerivedCaches(ctx.projectId()); + // 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지 + // 않는다(2026-09-03 사용자 지시). + showToast(L("B03_File_Upload_Success"), "success"); + + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollAnalysis(ctx.projectId()); + + if (analysisComplete) { + navigateTo(ctx.completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const failed = targetStates.find((state) => state.uploadStatus === "uploading"); + const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); + if (failed) ctx.showErrorMessage(failed.slot, detail); + ctx.pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; + showToast(L("B03_File_Upload_Failed"), "error"); + } finally { + ctx.setUploading(false); + } + } + + async function registerB03ServiceWorker(): Promise { + if (!("serviceWorker" in navigator)) { + showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); + return; + } + try { + const registration = await navigator.serviceWorker.register( + new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url), + { type: "module" }, + ); + registration.active?.postMessage({ type: "B03_SW_PING" }); + showToast(L("B03_File_ServiceWorker_Ready"), "info"); + } catch { + showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); + } + } + + return { + pollAnalysis, + detectPausedUploads, + relockWhileInitialPipelineRuns, + startChunkedUpload, + registerB03ServiceWorker, + }; +} diff --git a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts new file mode 100644 index 00000000..a60aa3f7 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts @@ -0,0 +1,136 @@ +/* ============================================================================= + * B03_FileInput_UI_Page_Rules.ts + * 파일 입력 화면의 판정 규칙 — 어떤 카드가 지금 필수인가, 고른 파일이 그 자리에 맞는가, + * 업로드를 시작해도 되는가, 서버 파일이 어느 카드로 가는가. + * + * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * 화면 상태를 가두지 않고 **인자로 받는 순수 함수**만 둔다 — 판정 결과는 종전과 같다. + * ========================================================================== */ + +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, + TERRAIN_SLOTS, + type FileSlot, + type FileSlotState, +} from "./B03_FileInput_UI_Support"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export type SlotMap = Map; + +/** 노선 도형 카드에 shapefile이 들어와 있는가. */ +export function routeIsShapefile(slots: SlotMap): boolean { + const state = slots.get("csv"); + const name = state?.file?.name ?? state?.serverUploaded?.name; + return getExtension(name ?? "") === ".shp"; +} + +/** + * 이 카드가 지금 필수인가. + * + * shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 — + * CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31). + */ +export function isSlotRequired( + state: FileSlotState, + slots: SlotMap, + lasFreeDesign: boolean, +): boolean { + if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(slots); + // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. + if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; + return state.isRequired; +} + +/** 고른 파일이 그 카드의 확장자·크기 규칙에 맞는가. 어긋나면 안내 문구를 돌려준다. */ +export function validateFileForSlot(file: File, state: FileSlotState): string | null { + const extension = getExtension(file.name); + const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; + if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); + if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); + return null; +} + +/** 업로드를 시작해도 되는가. 안 되면 첫 번째 사유를 돌려준다. */ +export function validateSlots( + slots: SlotMap, + selected: FileSlotState[], + activeProjectId: string, + lasFreeDesign: boolean, +): string | null { + if (!activeProjectId) return L("B03_File_Error_Project"); + if (selected.length === 0) return L("B03_File_Error_Required"); + if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); + // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — + // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). + const missingRequired = Array.from(slots.values()).some( + (state) => isSlotRequired(state, slots, lasFreeDesign) && !state.file && !state.serverUploaded, + ); + if (missingRequired) return L("B03_File_Error_RequiredSlots"); + if (!lasFreeDesign) { + const lasState = slots.get("las_laz"); + if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); + } + for (const state of selected) { + if (state.error) return state.error; + const validation = validateFileForSlot(state.file!, state); + if (validation) return validation; + } + return null; +} + +/** + * 서버 현황의 파일 한 건이 어느 카드로 가는가. + * + * PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 저장 경로로 + * 가린다(2026-08-31). 옛 프로젝트의 노선은 `.csv`로 올라가 있어 계획노선 도형 카드로 + * 보낸다(이제 새로 받지는 않는다, 2026-09-03). + */ +export function slotForOverviewFile( + file: UploadOverviewFile, + slots: SlotMap, +): FileSlot | undefined { + const extension = `.${file.file_type.toLowerCase()}`; + if (extension === ".prj") { + return (file.relative_path ?? "").includes("/input/shp/") ? "route_prj" : "prj"; + } + if (extension === ".csv") return "csv"; + return Array.from(slots.values()).find( + (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_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts new file mode 100644 index 00000000..d85fe6ce --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * B03_FileInput_UI_Preview.ts + * 업로드 자료 카드 미리보기 — 분석 메타데이터의 범위(bounds)만으로 그리는 작은 SVG. + * + * 재료는 이미 서버가 낸다(업로드 분석기의 `bounds`, 계획노선 shapefile은 `preview_path`). + * **파일을 다시 읽지 않으므로 추가 지연이 없다** — 사용자 제약 「오래 걸리면 안 됨」 + * (2026-09-04 확정). 라이다 점구름·GeoTIFF 래스터는 **범위 사각형만** 그린다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export interface PreviewExtent { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; +// 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 파일마다 +// 들쭉날쭉하지 않게. 실제 폭은 CSS 가 100% 로 늘리고, 이 값들은 좌표계 기준일 뿐이다. +const VIEW_WIDTH = 320; +const VIEW_HEIGHT = 120; +const PADDING = 6; + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * 분석기마다 범위를 담는 모양이 다르다 — 세 가지를 한 형태로 모은다. + * shapefile·노선 CSV `{x_min,…}` / LAS `{x:[min,max],…}` / GeoTIFF `{left,bottom,right,top}`. + */ +export function readExtent(metadata: Record | undefined): PreviewExtent | null { + const bounds = metadata?.bounds as Record | undefined; + if (!bounds) return null; + + const pairs: [number | null, number | null, number | null, number | null][] = [ + [finite(bounds.x_min), finite(bounds.x_max), finite(bounds.y_min), finite(bounds.y_max)], + [finite(bounds.left), finite(bounds.right), finite(bounds.bottom), finite(bounds.top)], + ]; + const xs = bounds.x as unknown[] | undefined; + const ys = bounds.y as unknown[] | undefined; + if (Array.isArray(xs) && Array.isArray(ys)) { + pairs.push([finite(xs[0]), finite(xs[1]), finite(ys[0]), finite(ys[1])]); + } + for (const [xMin, xMax, yMin, yMax] of pairs) { + if (xMin === null || xMax === null || yMin === null || yMax === null) continue; + if (xMax <= xMin || yMax <= yMin) continue; + return { xMin, xMax, yMin, yMax }; + } + return null; +} + +/** 계획노선 shapefile이 함께 낸 솎은 좌표열(없으면 null). */ +export function readPreviewPath( + metadata: Record | undefined, +): number[][][] | null { + const raw = metadata?.preview_path; + if (!Array.isArray(raw) || raw.length === 0) return null; + const parts: number[][][] = []; + for (const part of raw) { + if (!Array.isArray(part)) continue; + const points: number[][] = []; + for (const point of part) { + if (!Array.isArray(point)) continue; + const x = finite(point[0]); + const y = finite(point[1]); + if (x !== null && y !== null) points.push([x, y]); + } + if (points.length >= 2) parts.push(points); + } + return parts.length ? parts : null; +} + +/** 라이다 분석기가 훑는 길에 주워 둔 탑뷰 점(없으면 null). */ +export function readPreviewPoints( + metadata: Record | undefined, +): number[][] | null { + const raw = metadata?.preview_points; + if (!Array.isArray(raw) || raw.length === 0) return null; + const points: number[][] = []; + for (const point of raw) { + if (!Array.isArray(point)) continue; + const x = finite(point[0]); + const y = finite(point[1]); + if (x !== null && y !== null) points.push([x, y]); + } + return points.length ? points : null; +} + +/** GeoTIFF 저해상 썸네일(data URL). 오버뷰가 없는 파일은 만들지 않아 null 이다. */ +export function readThumbnail(metadata: Record | undefined): string | null { + const raw = metadata?.preview_thumbnail; + return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null; +} + +/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */ +const SHAPE_TYPE_LABELS: Record = { + 0: "빈 도형", + 1: "점", + 3: "폴리라인", + 5: "폴리곤", + 8: "다중점", + 11: "점(3D)", + 13: "폴리라인(3D)", + 15: "폴리곤(3D)", + 18: "다중점(3D)", + 21: "점(M)", + 23: "폴리라인(M)", + 25: "폴리곤(M)", + 28: "다중점(M)", + 31: "복합면", +}; + +/** 129.002890277778 → 「129.0029°E」. */ +function meridianLabel(degrees: number): string { + const rounded = Math.abs(Math.round(degrees * 10000) / 10000); + return `${rounded}°${degrees < 0 ? "W" : "E"}`; +} + +/** + * 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **파일 안의 중요한 값**을 뽑는다 + * (2026-09-04 사용자 지시 — 개수만이 아니라 내용이 보이게). 첫 줄은 굵게, 나머지는 작게. + */ +export function readFacts(metadata: Record | undefined): string[] { + if (!metadata) return []; + const facts: string[] = []; + const number = (value: unknown): number | null => finite(value); + const text = (value: unknown): string | null => + typeof value === "string" && value.trim() ? value.trim() : null; + const round = (value: number): string => Math.round(value).toLocaleString(); + + // SHX — 도형 종류·개수와 그 도형이 덮는 범위. + const shapes = number(metadata.shape_count); + if (shapes !== null) { + const kind = SHAPE_TYPE_LABELS[number(metadata.shape_type) ?? -1]; + const count = `도형 ${shapes.toLocaleString()}개`; + facts.push(kind ? `${kind} · ${count}` : count); + const width = number(metadata.extent_width_m); + const height = number(metadata.extent_height_m); + if (width !== null && height !== null) facts.push(`범위 ${round(width)}×${round(height)} m`); + } + + // DBF — 레코드·속성 개수와 속성 이름(무엇이 든 표인지). + const records = number(metadata.record_count); + const fields = number(metadata.field_count); + if (records !== null) { + const count = `레코드 ${records.toLocaleString()}개`; + facts.push(fields !== null ? `${count} · 속성 ${fields}개` : count); + } + const names = Array.isArray(metadata.field_names) + ? metadata.field_names.filter((name): name is string => typeof name === "string") + : []; + if (names.length) facts.push(names.slice(0, 4).join(", ") + (names.length > 4 ? " …" : "")); + + // CPG — 인코딩 이름과 그 인코딩으로 읽은 글자 견본(깨지면 눈에 보인다). + const encoding = text(metadata.encoding); + if (encoding) facts.push(`인코딩 ${encoding}`); + const sample = text(metadata.encoding_sample); + if (sample) facts.push(`견본 「${sample}」`); + + // TFW — 픽셀 크기와 회전. + const pixelX = number(metadata.pixel_size_x); + const pixelY = number(metadata.pixel_size_y); + if (pixelX !== null && pixelY !== null) { + facts.push(`픽셀 ${Math.abs(pixelX)}×${Math.abs(pixelY)} m`); + const rotated = + (number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0; + facts.push(rotated ? "회전 있음" : "회전 없음"); + } + + // PRJ — 좌표계 이름·EPSG 와 원점(중앙자오선)·길이 단위. + const name = text(metadata.name); + const epsgLabel = readCrsLabel(metadata); + if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name); + else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`); + const meridian = number(metadata.central_meridian_deg); + const unit = text(metadata.unit_name); + const origin = [ + meridian === null ? null : `중앙자오선 ${meridianLabel(meridian)}`, + unit ? `단위 ${/^met(er|re)s?$/i.test(unit) ? "m" : unit}` : null, + ].filter((part): part is string => part !== null); + if (origin.length) facts.push(origin.join(" · ")); + const vertical = metadata.vertical_crs as Record | null | undefined; + if (vertical && typeof vertical === "object") { + const verticalName = text(vertical.name); + if (verticalName) facts.push(`높이 기준 ${verticalName}`); + } + + return facts.slice(0, 3); +} + +/** + * 좌표계 코드(EPSG 숫자). 서로 다르면 범위 대조를 하지 않는다. + * 분석기마다 숫자(`5176`)로도, 문자열(`"EPSG:5176"`)로도 온다 — 숫자만 뽑아 맞춘다. + */ +export function readCrsLabel(metadata: Record | undefined): string | null { + const epsg = metadata?.epsg ?? metadata?.crs_epsg ?? metadata?.crs; + if (typeof epsg === "number" && Number.isFinite(epsg)) return String(epsg); + if (typeof epsg !== "string") return null; + const digits = epsg.match(/\d{4,6}/); + return digits ? digits[0] : null; +} + +/** a 가 b 안에 들어오는가(경계 포함). */ +export function isInside(inner: PreviewExtent, outer: PreviewExtent): boolean { + return ( + inner.xMin >= outer.xMin && + inner.xMax <= outer.xMax && + inner.yMin >= outer.yMin && + inner.yMax <= outer.yMax + ); +} + +/** 자기 범위를 화면 좌표(위가 북쪽)로 옮기는 변환을 만든다. */ +function projector(extent: PreviewExtent): (x: number, y: number) => [number, number] { + const spanX = extent.xMax - extent.xMin; + const spanY = extent.yMax - extent.yMin; + const scale = Math.min((VIEW_WIDTH - PADDING * 2) / spanX, (VIEW_HEIGHT - PADDING * 2) / spanY); + const offsetX = (VIEW_WIDTH - spanX * scale) / 2; + const offsetY = (VIEW_HEIGHT - spanY * scale) / 2; + return (x, y) => [ + offsetX + (x - extent.xMin) * scale, + // SVG 는 y 가 아래로 자라므로 뒤집는다. + VIEW_HEIGHT - offsetY - (y - extent.yMin) * scale, + ]; +} + +export interface PreviewOptions { + metadata?: Record; + /** 업로드·분석이 끝난 카드인지 — 끝난 카드에만 「값 없음」 안내를 보인다. */ + completed?: boolean; + /** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */ + terrainExtent?: PreviewExtent | null; + /** 지형 자료 좌표계 — 노선과 다르면 대조를 생략한다(재투영은 하지 않는다). */ + terrainCrs?: string | null; + /** + * 이 자료의 좌표계를 대신 알려 준다. shapefile 자체에는 좌표계가 없어 + * 같은 세트의 `.prj` 값이 정본이다(2026-09-04 실측 — `epsg: null`). + */ + crsFallback?: string | null; +} + +/** + * 카드 본문에 미리보기를 그린다. 그릴 것이 없으면 비우고 숨긴다(값 없으면 미표시). + */ +export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): void { + host.replaceChildren(); + host.classList.remove("is-visible", "is-warning"); + host.removeAttribute("title"); + + const extent = readExtent(options.metadata); + if (!extent) { + // 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로 보인다 (2026-09-04 사용자 지시). + const facts = readFacts(options.metadata); + if (facts.length === 0) { + if (!options.completed) return; + const empty = document.createElement("div"); + empty.className = "b03-file__preview-empty"; + empty.textContent = "보여 줄 값이 없음"; + host.append(empty); + host.classList.add("is-visible"); + return; + } + const box = document.createElement("div"); + box.className = "b03-file__preview-facts"; + facts.forEach((fact, index) => { + const line = document.createElement("span"); + line.className = index === 0 ? "b03-file__preview-fact" : "b03-file__preview-fact-sub"; + line.textContent = fact; + box.append(line); + }); + host.title = facts.join(" · "); + host.append(box); + host.classList.add("is-visible"); + return; + } + + // GeoTIFF 썸네일이 있으면 그림 그대로 보인다(오버뷰가 있는 파일만 만들어진다). + const thumbnail = readThumbnail(options.metadata); + if (thumbnail) { + const image = document.createElement("img"); + image.className = "b03-file__preview-thumb"; + image.src = thumbnail; + image.alt = L("B03_File_Preview_Extent"); + host.title = L("B03_File_Preview_Extent"); + host.append(image); + host.classList.add("is-visible"); + return; + } + + const path = readPreviewPath(options.metadata); + const cloud = readPreviewPoints(options.metadata); + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.setAttribute("role", "img"); + const project = projector(extent); + + // 자료가 덮는 땅 범위 — 그릴 도형이 없는 자료에만 그린다. 계획노선은 선 자체가 + // 범위를 보여 주므로 점선 상자를 빼고 선만 남긴다(2026-09-04 사용자 지시). + if (!path) { + const [rectX, rectTop] = project(extent.xMin, extent.yMax); + const [rectRight, rectBottom] = project(extent.xMax, extent.yMin); + const rect = document.createElementNS(SVG_NS, "rect"); + rect.setAttribute("class", "b03-file__preview-extent"); + rect.setAttribute("x", String(rectX)); + rect.setAttribute("y", String(rectTop)); + rect.setAttribute("width", String(rectRight - rectX)); + rect.setAttribute("height", String(rectBottom - rectTop)); + svg.append(rect); + } + + let label = L("B03_File_Preview_Extent"); + // 라이다 탑뷰 점 그림 — 분석기가 훑는 길에 주워 둔 XY 를 그대로 찍는다. + if (cloud) { + label = `점 ${cloud.length.toLocaleString()}개 (솎은 탑뷰)`; + const dots = document.createElementNS(SVG_NS, "path"); + dots.setAttribute("class", "b03-file__preview-cloud"); + dots.setAttribute( + "d", + cloud + .map(([x, y]) => { + const [sx, sy] = project(x, y); + return `M${sx.toFixed(1)} ${sy.toFixed(1)}h0.7`; + }) + .join(""), + ); + svg.append(dots); + } + if (path) { + label = L("B03_File_Preview_Route"); + for (const part of path) { + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("class", "b03-file__preview-route"); + line.setAttribute( + "points", + part + .map(([x, y]) => + project(x, y) + .map((value) => value.toFixed(2)) + .join(","), + ) + .join(" "), + ); + svg.append(line); + } + } + + // 노선이 지형 자료 범위를 벗어나면 카드에서 바로 보이게 경고색을 건다. + // 솎은 좌표열이 없어도(옛 업로드) 범위끼리는 대조된다. + // 좌표계가 다르면 대조하지 않는다(프론트에서 재투영하지 않음). + const routeCrs = readCrsLabel(options.metadata) ?? options.crsFallback ?? null; + const sameCrs = !routeCrs || !options.terrainCrs || routeCrs === options.terrainCrs; + if (options.terrainExtent && sameCrs) { + const inside = isInside(extent, options.terrainExtent); + host.classList.toggle("is-warning", !inside); + label = L(inside ? "B03_File_Preview_Inside" : "B03_File_Preview_Outside"); + } + + svg.setAttribute("aria-label", label); + host.title = label; + host.append(svg); + host.classList.add("is-visible"); +} diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index c0f8768f..1aa9cba5 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -19,9 +19,11 @@ .b03-file__control-panel { display: flex; flex-direction: column; - gap: var(--spacing-20); + gap: var(--spacing-16); background: var(--color-canvas, #ffffff); - padding: var(--spacing-32); + /* 여백 32 → 16 (2026-09-03) — 고르는 자리만 남기고 안내를 좌측 패널로 뺐다. + `--spacing-20`은 테마에 없는 토큰이라 선언째 무효가 된다(패딩이 통째로 사라짐). */ + padding: var(--spacing-16); border-radius: var(--radius-large, 24px); /* Wiza 24px 대형 둥글기 */ border: 1px solid var(--color-mist, #e6e2e3); box-shadow: var(--shadow-lg); @@ -43,15 +45,40 @@ } /* Wiza 스타일 가이드의 Lavender Glow 그라데이션 및 soft purple border hover */ +/* 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함] · [파일 업로드](2026-09-03). + 좁아지면 아래로 접히되, 선택 영역이 남는 폭을 먹는다. */ +/* 셋을 같은 폭·같은 높이로 나눈다 — 열 1:1:1(2026-09-03 사용자 지시). + flex 로는 버튼·드롭존의 내용 폭이 배분에 섞여 288·254·288 로 어긋났다. 격자는 + 칸을 먼저 나누므로 내용과 무관하게 정확히 1:1:1 이 된다. 좁아지면 칸이 접힌다. */ +.b03-file__pick-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + align-items: stretch; + gap: var(--spacing-8); +} + +.b03-file__pick-row > * { + min-width: 0; +} + +/* 칸을 꽉 채워야 세 칸의 높이가 같아진다 — 버튼은 기본이 내용 크기다. */ +.b03-file__pick-row .b03-file__temp-picker > .ui-btn, +.b03-file__pick-row > .ui-btn { + width: 100%; + height: 100%; +} + +/* 한 줄 바(2026-09-03) — 종전 160px 블록은 안내 문구를 담느라 컸다. 문구는 좌측 + 패널로 갔으므로 여기는 「끌어 놓거나 눌러서 고르는 자리」 표시만 한다. */ .b03-file__dropzone { - min-height: 160px; - padding: var(--spacing-32) var(--spacing-24); - border: 2px dashed var(--color-mist, #e6e2e3); - border-radius: var(--radius-large, 24px); + padding: var(--spacing-12) var(--spacing-16); + border: 1px dashed var(--color-mist, #e6e2e3); + border-radius: var(--radius-cards, 8px); background: var(--color-paper, #f6f7fa); display: flex; - flex-direction: column; - align-items: center; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; justify-content: center; gap: var(--spacing-8); cursor: pointer; @@ -77,7 +104,7 @@ .b03-file__dropzone strong { color: var(--color-deep-iris, #26114a); - font-size: var(--text-body, 16px); + font-size: var(--text-body-sm, 14px); font-weight: var(--font-weight-medium, 500); } @@ -86,11 +113,16 @@ color: var(--color-slate, #615e6e); } +/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만 + 자리를 차지한다(2026-09-03 사용자 지시). */ .b03-file__error { - min-height: var(--spacing-16); color: var(--color-danger, #dc2626); font-size: var(--text-body-sm, 14px); - margin: var(--spacing-8) 0; + margin: 0; +} + +.b03-file__error:empty { + display: none; } .b03-file__resume { @@ -185,14 +217,124 @@ .b03-file__columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-24); + gap: var(--spacing-16); align-items: start; } +/* LAS 없이 설계 토글 — 파일 입력 컨테이너의 스위치(2026-09-03 사용자 지시). + B04 좌측 패널의 체크 줄(`b04-surface__check`)과 같은 서식이다. */ +.b03-file__lasfree { + display: flex; + align-items: center; + gap: var(--spacing-8); + align-self: flex-start; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-mist, #e6e2e3); + border-radius: var(--radius-buttons, 8px); + font-size: var(--text-body-sm, 14px); + color: var(--color-deep-iris, #26114a); + cursor: pointer; + transition: border-color var(--transition-base, 0.2s); +} + +.b03-file__lasfree:hover { + border-color: var(--color-royal-amethyst, #3e0079); +} + +.b03-file__lasfree input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-royal-amethyst, #3e0079); + cursor: pointer; +} + +/* 켜져 있으면 스위치 자신도 켜진 티를 낸다 — 지형 컨테이너가 왜 잠겼는지의 근거. */ +.b03-file__lasfree:has(input:checked) { + border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-mist-violet, #edecff); + font-weight: var(--font-weight-medium, 500); +} + +/* LAS 없이 설계를 켜면 지형 컨테이너는 받지 않는다 — 잠긴 것이 보이게 흐린다. */ +.b03-file__group--disabled { + opacity: 0.55; +} + +.b03-file__card--disabled { + background: var(--color-paper, #f6f7fa); + border-style: dashed; +} + +.b03-file__card--disabled .b03-file__card-select { + cursor: not-allowed; +} + +/* 좌측 안내 패널이 열리면 본문을 그만큼 밀어낸다 — 공용 워크플로 레이아웃(B04·B05)의 + `.ui-workflow-layout__body` 규칙과 같은 값. 접히면 손잡이 폭만 남긴다. */ +.b03-file__main-layout { + transition: padding-left var(--transition-base); +} + +.b03-file.is-options-open .b03-file__main-layout { + padding-left: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48))); +} + +.b03-file:not(.is-options-open) .b03-file__main-layout { + padding-left: var(--spacing-24); +} + +@media (max-width: 860px) { + .b03-file.is-options-open .b03-file__main-layout { + padding-left: 0; + } +} + +/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리. + 양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은 + `ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은 + `__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */ +.b03-file__form { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + padding: var(--spacing-16); +} + +.b03-file__guide-group { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin: 0; + padding: var(--spacing-16); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); +} + +.b03-file__guide-legend { + margin: 0; + padding: 0 var(--spacing-8); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은 + `--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */ +.b03-file__guide-list { + margin: 0; + padding-left: var(--spacing-16); + display: flex; + flex-direction: column; + gap: var(--spacing-4); + font-size: var(--text-body-sm); + color: var(--color-text-body); +} + .b03-file__group { display: flex; flex-direction: column; - gap: var(--spacing-20); + gap: var(--spacing-12); } .b03-file__group-hint { @@ -201,17 +343,20 @@ margin: calc(var(--spacing-8) * -1) 0 0 0; } +/* 제목 24px → 본문 크기(2026-09-03) — 컨테이너가 둘뿐이라 큰 제목이 자리만 먹었다. */ .b03-file__group-title { - font-size: var(--text-subheading, 24px); + font-size: var(--text-body, 16px); color: var(--color-deep-iris, #26114a); font-family: var(--font-britti-sans, inherit); - margin: 0 0 var(--spacing-8) 0; + margin: 0; } +/* 카드가 좁아지면 한 줄에 제목·상태·[선택]이 못 들어가 제목이 글자 단위로 접힌다 + (2026-09-03 실측: 안내 패널을 연 상태에서 카드 폭 210px). 폭이 모자라면 열을 줄인다. */ .b03-file__group-content { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--spacing-24); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: var(--spacing-12); } /* 업로드 중에는 화면 조작을 막는다 — 중복 업로드·단계 이동 방지(2026-08-08 사용자 지시). */ @@ -235,16 +380,17 @@ } /* Wiza 8px radius 카드 */ +/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) — + 파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */ .b03-file__card { - min-height: 220px; - padding: var(--spacing-24); /* 내부 전체 패딩 확대 */ + padding: var(--spacing-12) var(--spacing-16); border: 1px solid var(--color-mist, #e6e2e3); border-radius: var(--radius-cards, 8px); background: var(--color-canvas, #ffffff); box-shadow: var(--shadow-sm); display: flex; flex-direction: column; - gap: var(--spacing-20); + gap: var(--spacing-8); transition: transform var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s), @@ -277,9 +423,11 @@ /* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이 글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */ +/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로 + 내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */ .b03-file__card-header { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--spacing-8); align-items: center; } @@ -300,22 +448,20 @@ .b03-file__card-heading { min-width: 0; - flex: 1 1 55%; + flex: 1 1 auto; display: flex; flex-direction: column; - gap: 2px; - margin-top: 2px; /* 제목 상단 여유 추가 */ + gap: 1px; } .b03-file__card-label { color: var(--color-deep-iris, #26114a); font-size: var(--text-body-sm, 14px); font-weight: var(--font-weight-medium, 500); - /* 한글은 글자 단위로 끊기므로 어절을 지켜 준다 — "계획 -노선 -도형" 방지. */ - word-break: keep-all; - overflow-wrap: break-word; + /* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .b03-file__card-ext, @@ -357,25 +503,29 @@ } .b03-file__card-content { - flex: 1; display: flex; flex-direction: column; - justify-content: flex-end; - gap: var(--spacing-16); /* 버튼 하단 및 내부 간격 확대 */ - padding-bottom: var(--spacing-8); /* 파일 선택 버튼 하단 패딩 확보 */ + gap: var(--spacing-8); } +/* 빈 칸은 제목 줄만 남긴다 — 파일이 붙으면 이름·크기가, 올라가는 중에는 진행바가 선다. */ +.b03-file__card--empty .b03-file__card-content { + display: none; +} + +/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */ .b03-file__card-select { - width: 100%; - min-height: 40px; /* 버튼 높이 증가 */ + flex: 0 0 auto; + min-height: 26px; + padding: 0 var(--spacing-8); border: 1px solid var(--color-mist, #e6e2e3); border-radius: var(--radius-buttons, 8px); color: var(--color-deep-iris, #26114a); background: var(--color-canvas, #ffffff); font-weight: var(--font-weight-medium, 500); - font-size: var(--text-body-sm, 14px); + font-size: var(--text-caption, 12px); + white-space: nowrap; cursor: pointer; - margin-bottom: var(--spacing-8); /* 파일 선택 버튼 하단 마진 추가 */ transition: background var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s); @@ -395,12 +545,18 @@ overflow-wrap: anywhere; } +/* 진행바·속도·예상완료는 **올라가는 동안만** 보인다(2026-09-03) — 끝난 카드에 남겨 두면 + 읽을 일 없는 세 줄이 카드 높이를 그대로 차지했다. */ .b03-file__progress-section { - display: flex; + display: none; flex-direction: column; gap: var(--spacing-8); } +.b03-file__card--uploading .b03-file__progress-section { + display: flex; +} + .b03-file__progress-bar-container { width: 100%; height: 6px; @@ -434,17 +590,120 @@ } .b03-file__card--empty .b03-file__file-info, +.b03-file__card--empty .b03-file__preview, .b03-file__card--empty .b03-file__progress-section, .b03-file__card--empty .b03-file__error-message { display: none; } +/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형. + 값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */ +.b03-file__preview { + display: none; + margin-top: 6px; +} + +/* 카드 한 행을 전부 쓰고 높이는 고정한다 (2026-09-04 사용자 지시) — 카드 높이가 + 파일마다 들쭉날쭉하지 않게. 그림·썸네일·값 표시가 모두 같은 상자를 쓴다. */ +.b03-file__preview.is-visible { + display: flex; + grid-column: 1 / -1; + align-items: center; + justify-content: center; + width: 100%; + height: 120px; + overflow: hidden; + border: 1px solid var(--color-border, #dcdce6); + border-radius: var(--radius-cards, 8px); + background: var(--color-surface, #fff); +} + +.b03-file__preview svg { + display: block; + width: 100%; + height: 100%; +} + +/* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */ +.b03-file__preview-cloud { + fill: none; + stroke: var(--color-text-muted, #5a5a72); + stroke-linecap: round; + stroke-opacity: 0.55; + stroke-width: 0.7; +} + +/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라 + 다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다 + (2026-09-04 사용자 지적). */ +.b03-file__preview-thumb { + max-width: 100%; + max-height: 100%; + border: 1px dashed var(--color-border, #9aa); + image-rendering: pixelated; + object-fit: contain; +} + +/* 그릴 도형이 없는 부속 파일 — 구분되는 값을 큰 글씨로. */ +.b03-file__preview-facts { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px; + text-align: center; +} + +.b03-file__preview-fact { + color: var(--color-text, #1c1c28); + font-size: var(--text-body, 15px); + font-weight: 600; +} + +.b03-file__preview-fact-sub { + color: var(--color-text-muted, #5a5a72); + font-size: var(--text-caption, 12px); +} + +.b03-file__preview-empty { + color: var(--color-text-muted, #8a8aa0); + font-size: var(--text-caption, 12px); +} + +.b03-file__preview-extent { + fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent); + stroke: var(--color-border, #9aa); + stroke-dasharray: 4 3; + stroke-width: 1; +} + +.b03-file__preview-route { + fill: none; + /* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서 + 붉은색으로 덮어써 경고로 쓴다. */ + stroke: var(--color-success, #16a34a); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; +} + +/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */ +.b03-file__preview.is-warning .b03-file__preview-route { + stroke: var(--color-danger, #d33); +} + +.b03-file__preview.is-warning .b03-file__preview-extent { + stroke: var(--color-danger, #d33); + stroke-dasharray: 3 2; +} + .b03-file__card--selected .b03-file__progress-section { display: none; } -.b03-file__card--uploading .b03-file__card-select, -.b03-file__card--completed .b03-file__card-select { +/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아 + 끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */ +.b03-file__card--uploading .b03-file__card-select { display: none; } @@ -455,65 +714,3 @@ .b03-file__card--error .b03-file__progress-section { display: none; } - -.b03-file__results { - margin: var(--spacing-8) 0 0 0; - padding: 0; - list-style: none; - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-cards, 8px); - overflow: hidden; - background: var(--color-canvas, #ffffff); -} - -.b03-file__results:empty { - display: none; -} - -.b03-file__results li { - padding: var(--spacing-12) var(--spacing-16); - display: flex; - flex-direction: column; - gap: 2px; - border-bottom: 1px solid var(--color-mist, #e6e2e3); - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); -} - -.b03-file__results li:last-child { - border-bottom: 0; -} - -@media (max-width: 1440px) { - /* 좁아지면 두 컨테이너를 위아래로 쌓는다 — 카드가 눌려 글자가 접히는 것을 막는다. */ - .b03-file__columns { - grid-template-columns: 1fr; - } -} - -@media (max-width: 720px) { - .b03-file { - padding: var(--spacing-16) var(--spacing-12); - } - - .b03-file__group-content { - grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */ - } -} - -/* LAS 없는 설계(도엽등고선 기반) 토글 — 2026-08-30 */ -.b03-file__lasfree { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 2px; - font-size: var(--text-body); - color: var(--color-text-body); - cursor: pointer; - user-select: none; -} - -.b03-file__card--disabled { - opacity: 0.45; - pointer-events: none; -} diff --git a/B03_FileInput/B03_FileInput_UI_Style_Temp.css b/B03_FileInput/B03_FileInput_UI_Style_Temp.css index 4dfc2cde..11edd0e0 100644 --- a/B03_FileInput/B03_FileInput_UI_Style_Temp.css +++ b/B03_FileInput/B03_FileInput_UI_Style_Temp.css @@ -4,25 +4,11 @@ * 모달 껍데기(.b03-file__modal / -backdrop)는 기존 확인 모달 스타일을 그대로 쓴다. * ========================================================================== */ +/* 버튼 하나만 담는 자리 — 테두리를 또 두르면 버튼 테두리와 이중이 된다 + (2026-09-03 사용자 지시). */ .b03-file__temp-picker { display: flex; align-items: center; - gap: var(--spacing-12); - flex-wrap: wrap; - padding: var(--spacing-12); - border: 1px dashed var(--color-border); - border-radius: var(--radius-cards); -} - -.b03-file__temp-summary { - font-family: var(--font-body); - font-size: var(--text-caption); - color: var(--color-text-secondary); -} - -.b03-file__temp-summary.is-active { - color: var(--color-success); - font-weight: 700; } .b03-file__temp-modal { diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 4ee67cc8..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; @@ -39,7 +45,7 @@ export interface FileSlotState extends SlotConfig { * 로컬 파일을 새로 고르지 않아도 카드에 완료 상태로 표시하고, 이 슬롯에 새 파일을 * 올리면 교체 확인 모달을 띄우는 근거가 된다(2026-08-04 사용자 지시). */ - serverUploaded?: { name: string; sizeMb: number }; + serverUploaded?: { name: string; sizeMb: number; metadata?: Record }; } export interface StoredUploadSession { @@ -60,7 +66,9 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [ slot: "csv", labelKey: "B03_File_Slot_PlannedRoute", icon: "⌁", - extensions: [".csv", ".shp"], + // `.csv`는 받지 않는다 — 내부 계산이 만드는 파일이라 사용자가 넣는 자료가 아니다 + // (2026-09-03 사용자 지시). 슬롯 키 `csv`는 저장·서버 규약이라 그대로 둔다. + extensions: [".shp"], isRequired: true, }, { @@ -121,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() : ""; @@ -208,15 +235,16 @@ export function createFileCardTemplate(): HTMLTemplateElement {
+
-
+
diff --git a/B03_FileInput/B03_FileInput_UI_TempPicker.ts b/B03_FileInput/B03_FileInput_UI_TempPicker.ts index a94754dd..52d84a3c 100644 --- a/B03_FileInput/B03_FileInput_UI_TempPicker.ts +++ b/B03_FileInput/B03_FileInput_UI_TempPicker.ts @@ -46,19 +46,15 @@ export function createTempPicker( variant: "ghost", onClick: () => void openModal(), }); - const summary = document.createElement("span"); - summary.className = "b03-file__temp-summary"; - summary.textContent = L("B03_Temp_None"); - root.append(openButton, summary); + // 선택 요약(「선택된 보관 자료 없음」·묶음 이름)은 두지 않는다 — 불러온 자료는 카드가 + // 곧바로 보여 주고, 고르기 전 상태는 [파일 업로드]가 잠긴 것으로 이미 드러난다 + // (2026-09-03 사용자 지시). + root.append(openButton); let selectedBatch: TempBatchItem | null = null; function applySelection(batch: TempBatchItem | null): void { selectedBatch = batch; - summary.textContent = batch - ? `${L("B03_Temp_Selected")} ${batch.name} (${batch.files.length}${L("B03_Temp_FileCount")})` - : L("B03_Temp_None"); - summary.classList.toggle("is-active", Boolean(batch)); onSelected(batch); } diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index dafe030b..b3bceb42 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -73,22 +73,6 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi } /** 업로드 결과 목록(파일명 + 저장 경로)을 다시 그린다. */ -export function renderUploadResults( - list: HTMLElement, - results: readonly UploadedFileResult[], -): void { - list.replaceChildren(); - for (const result of results) { - const item = document.createElement("li"); - const filename = document.createElement("strong"); - filename.textContent = result.original_filename; - const path = document.createElement("span"); - path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`; - item.append(filename, path); - list.append(item); - } -} - /** * 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다. * 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다. @@ -99,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_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 47e7a6d9..cec90c88 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -137,7 +137,7 @@ export interface SurfaceConfirmedResponse { z_min: number; z_max: number; } | null; - /** 계획노선(B03 CSV)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ + /** 계획노선(B03 정본)의 평면 범위. 지도 초기 화면을 도로 중심으로 맞출 때 쓴다. */ route_bounds: { x_min: number; x_max: number; y_min: number; y_max: number } | null; } @@ -308,7 +308,7 @@ export async function fetchGisGeoJson(projectId: string, layer: string): Promise }); } -/** 계획노선(B03 업로드 CSV)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */ +/** 계획노선(B03 정본)의 평면 점 목록. 사업지 좌표계(m) — 배경 지도 메타와 같은 좌표계다. */ export interface PlannedRouteResponse { status: string; points: Array<{ x: number; y: number }>; @@ -322,7 +322,7 @@ export async function fetchPlannedRoute(projectId: string): Promise; + /** 조각·구멍을 모두 편 링 목록. 도넛 유역과 떨어진 조각을 그대로 그린다(even-odd). */ + polygon_rings_lonlat?: Array>; area_m2: number; relief_m: number; flow_length_m: number; 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_Extent.py b/B04_PreProcess/B04_PreProcess_Engine_Extent.py index 2e5e931e..98865116 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Extent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Extent.py @@ -147,15 +147,11 @@ def project_epsg_from_prj(project_root: Path) -> str: """프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186). PRJ가 둘 이상 올라오므로(노선 세트 + 지형) 지형 PRJ를 고른다 — `find_project_prj`. + 판정은 작업 좌표계 창구(`resolve_project_crs`) 하나로 모았다(2026-09-03). """ - from common_util.common_util_crs import find_project_prj + from common_util.common_util_crs import resolve_project_crs - from .B04_PreProcess_Engine_VWorld import get_epsg_from_prj - - prj_path = find_project_prj(project_root) - if prj_path is not None: - return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) - return "EPSG:5186" + return resolve_project_crs(project_root) def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index 6e3d1c3d..566c2c33 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -510,7 +510,8 @@ def run_sheet_surface_analysis( 반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환). """ - from common_util.common_util_route_geometry import read_planned_route + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_route_geometry import load_design_route def _report(percent: int, stage: str, message: str) -> None: if on_progress is not None: @@ -522,10 +523,14 @@ def run_sheet_surface_analysis( processed_dir.mkdir(parents=True, exist_ok=True) models_dir.mkdir(parents=True, exist_ok=True) - planned = read_planned_route(route_csv_path) + # 노선을 **사업지(.prj) 좌표계로 옮긴 뒤** 도엽을 뜬다. 원본 좌표 그대로 뜨면 노선이 + # 5179, 지표면이 5176처럼 갈려 설계 계통(`load_design_route`)이 재투영한 노선이 지표면 + # 밖으로 나가고 트림이 노선을 통째로 지운다(2026-09-03 실측: shapefile 노선 + LAS 없는 + # 설계). LAS 경로·`build_sheet_surface_from_route`와 같은 창구를 쓰는 것이 요지다. + planned = load_design_route(project_root) if planned is None or len(planned.vertices) < 2: raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}") - epsg = planned.epsg or 5186 + crs = planned.crs_input or project_epsg_from_prj(project_root) route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) bounds_dict = { @@ -545,16 +550,15 @@ def run_sheet_surface_analysis( project_root, processed_dir, bounds_dict, - route_csv_path.parent, + # 노선 세트 폴더의 PRJ는 노선 좌표계다 — 지형 PRJ를 고르게 지형 폴더를 준다. + project_root / "B03_FileInput" / "input" / "prj", rebuild=False, - default_epsg=f"EPSG:{epsg}", + default_epsg=crs, report=_report, ) _report(70, "surface_model", "도엽등고선 3D 서피스 생성 중") - models = build_sheet_surface_model( - project_root, processed_dir, models_dir, route_xy, f"EPSG:{epsg}" - ) + models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, crs) if not models: raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.") 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_Flow.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py index a03b7521..216cfaf6 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py @@ -560,6 +560,25 @@ def polygonize_labels( return merged +def polygon_parts(geometry: Polygon | MultiPolygon) -> list[list[list[tuple[float, float]]]]: + """폴리곤을 **조각 목록**으로 편다 — 조각마다 [외곽 링, 구멍 링...] 순. + + `largest_ring()`은 가장 큰 조각의 외곽 하나만 낸다. 세부유역에서는 그 자리가 곧 + 중첩·빈공간이었다(2026-09-03 합성 실측): 아래 유역이 위 유역을 감싸면 구멍이 사라져 + 위 유역 256㎡가 통째로 덮이고, 한 관의 유역이 두 조각(225㎡+144㎡)이면 작은 144㎡가 + 빠져 빈공간이 됐다. 조각은 넓은 것부터, 좌표는 조각 안에서 외곽 다음에 구멍이다. + """ + if geometry.is_empty: + return [] + parts = list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + result: list[list[list[tuple[float, float]]]] = [] + for part in sorted(parts, key=lambda item: item.area, reverse=True): + rings = [[(float(x), float(y)) for x, y in part.exterior.coords]] + rings.extend([(float(x), float(y)) for x, y in hole.coords] for hole in part.interiors) + result.append(rings) + return result + + def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]: """폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다.""" if geometry.is_empty: 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 8615af91..ad43bcaa 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -7,10 +7,9 @@ from pathlib import Path from typing import Any from uuid import UUID -import aiomysql import numpy as np -from fastapi import APIRouter, Depends, Request -from fastapi.responses import JSONResponse, Response +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine import ( @@ -27,9 +26,28 @@ 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, ) + +# 700줄 분리로 옮긴 이름을 **진입 파일에서 그대로 다시 노출**한다 — 옛 경로를 보던 +# 호출부·테스트가 그대로 동작하게 하려는 것(2026-09-04, laptop-main 사례로 확인). +from B04_PreProcess.B04_PreProcess_Router_Progress import ( + PROGRESS_FILE_RELATIVE as PROGRESS_FILE_RELATIVE, +) +from B04_PreProcess.B04_PreProcess_Router_Progress import _progress_file_path as _progress_file_path +from B04_PreProcess.B04_PreProcess_Router_Progress import ( + read_surface_progress as read_surface_progress, +) +from B04_PreProcess.B04_PreProcess_Router_Progress import write_surface_progress +from B04_PreProcess.B04_PreProcess_Router_Status import ( + get_surface_model_preview as get_surface_model_preview, +) +from B04_PreProcess.B04_PreProcess_Router_Status import ( + get_wf1_analysis_status as get_wf1_analysis_status, +) +from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router from B04_PreProcess.B04_PreProcess_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, @@ -45,9 +63,6 @@ from B04_PreProcess.B04_PreProcess_Schema import ( ) from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection from common_util.common_util_auth import require_system_admin -from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_initial_snapshot import is_designing -from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( get_surface_confirmation_params, @@ -63,37 +78,10 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 -# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. -PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") - -def _progress_file_path(project_root: Path) -> Path: - return project_root.joinpath(*PROGRESS_FILE_RELATIVE) - - -def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: - """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" - try: - path = _progress_file_path(project_root) - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json( - path, - {"progress_percent": percent, "current_stage": stage, "message": message}, - ) - except OSError: - logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) - - -def read_surface_progress(project_root: Path) -> dict[str, Any] | None: - """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" - path = _progress_file_path(project_root) - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else None - except (OSError, ValueError): - return None +# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내 +# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04). +router.include_router(status_router) @router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) @@ -130,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 분석을 시작합니다.") @@ -141,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, @@ -518,201 +510,3 @@ async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsRespon status_code=500, content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."}, ) - - -@router.get("/{project_id}/surface/status") -async def get_wf1_analysis_status(project_id: UUID) -> dict: - """WF1 분석 상태를 조회한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """ - SELECT state, progress_percent, message, - (SELECT COUNT(*) FROM surface_models - WHERE project_id = %s) as model_count - FROM project_workflow_stages - WHERE project_id = %s AND stage_no = 1 - """, - (str(project_id), str(project_id)), - ) - row = await cursor.fetchone() - - # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) - if not row: - await cursor.execute( - """ - SELECT p.status as project_status, COUNT(sm.id) as model_count - FROM projects p - LEFT JOIN surface_models sm ON sm.project_id = p.id - WHERE p.id = %s AND p.deleted_at IS NULL - GROUP BY p.id, p.status - """, - (str(project_id),), - ) - fallback_row = await cursor.fetchone() - if not fallback_row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, - ) - model_count = int(fallback_row["model_count"]) - project_status = str(fallback_row.get("project_status") or "NEW") - - if project_status == "WF1_FAILED": - state = "FAILED" - progress_percent = 0 - message = "WF1 분석에 실패했습니다." - elif model_count > 0 or project_status == "WF1_COMPLETE": - state = "COMPLETE" - progress_percent = 100 - message = "WF1 분석이 완료되었습니다." - elif project_status == "WF1_ANALYZING": - state = "IN_PROGRESS" - progress_percent = 30 - message = "WF1 분석이 진행 중입니다." - else: - state = "NOT_STARTED" - progress_percent = 0 - message = "WF1 분석 대기 중입니다." - else: - state = row["state"] - progress_percent = row["progress_percent"] - message = row["message"] or "" - model_count = int(row["model_count"]) - - if state == "FAILED": - status = "failed" - current_stage = "failed" - if not message: - message = "WF1 분석에 실패했습니다." - elif state == "COMPLETE": - status = "completed" - progress_percent = 100 - current_stage = "completed" - if not message: - message = "WF1 분석이 완료되었습니다." - elif state == "IN_PROGRESS": - status = "in_progress" - current_stage = "surface_analysis" - if not message: - message = "WF1 분석이 진행 중입니다." - # 진행률 파일이 있으면 실제 단계별 진행률로 대체 - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) - if progress: - progress_percent = int(progress.get("progress_percent", progress_percent)) - current_stage = str(progress.get("current_stage", current_stage)) - message = str(progress.get("message", message)) - except LookupError: - pass - else: - status = "pending" - progress_percent = 0 - current_stage = "pending" - if not message: - message = "WF1 분석 대기 중입니다." - - # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 - # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). - # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): - status = "in_progress" - current_stage = "initial_design" - message = "초기 설계를 계산하는 중입니다." - except (LookupError, OSError, ValueError): - pass - - return { - "project_id": str(project_id), - "status": status, - "model_count": model_count, - "progress_percent": progress_percent, - "current_stage": current_stage, - "message": message, - } - except Exception: - logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, - ) - - -@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) -async def get_surface_model_preview( - request: Request, - project_id: UUID, - model_id: int, - smooth: bool = False, -) -> Response | JSONResponse: - """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - """ - SELECT model_type, model_file_path - FROM surface_models - WHERE id = %s AND project_id = %s - """, - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, - ) - model_type, model_file_path = row[0], row[1] - project_root = Path(resolve_stored_project_path(stored_path)) - if not model_file_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "모델 파일 경로가 없습니다."}, - ) - model_path = project_root / model_file_path - models_dir = model_path.parent - stem = model_path.stem - - ext = "ply" if model_type == "meshfree" else "glb" - if smooth and model_type in ("dtm", "tin"): - preview_filename = f"{stem}_smooth_preview.glb" - else: - preview_filename = f"{stem}_preview.{ext}" - - preview_path = models_dir / preview_filename - if not preview_path.is_file(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", - }, - ) - - media_type = "application/octet-stream" - if ext == "glb": - media_type = "model/gltf-binary" - elif ext == "ply": - media_type = "application/ply" - - # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). - return cached_file_response(request, preview_path, media_type, preview_filename) - - except Exception: - logger.exception( - "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, - ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index cb696cc1..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", @@ -161,7 +178,12 @@ def _payload( "index": basin.index, "chainage_m": round(basin.chainage_m, 2), "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), + # 옛 소비처를 위한 외곽 링 하나. 그리기는 아래 링 목록을 쓴다. "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], + # 조각·구멍을 모두 편 링 목록 — 도넛 유역과 떨어진 조각을 그대로 그린다. + "polygon_rings_lonlat": [ + [list(to_lonlat(x, y)) for x, y in ring] for ring in basin.boundary_rings + ], "area_m2": round(basin.area_m2, 1), "relief_m": round(basin.relief_m, 2), "flow_length_m": round(basin.flow_length_m, 1), @@ -192,9 +214,19 @@ def _basin_features( to_lonlat = context.to_lonlat features: list[dict[str, Any]] = [] for basin in detail.basins: - ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy] - if len(ring) < 4: + # GeoJSON 규격 그대로 — 조각마다 [외곽, 구멍...], 조각이 여럿이면 MultiPolygon. + parts = [ + [[list(to_lonlat(x, y)) for x, y in ring] for ring in part if len(ring) >= 4] + for part in basin.boundary_parts + ] + parts = [part for part in parts if part] + if not parts: continue + geometry = ( + {"type": "Polygon", "coordinates": parts[0]} + if len(parts) == 1 + else {"type": "MultiPolygon", "coordinates": parts} + ) features.append( { "type": "Feature", @@ -213,7 +245,7 @@ def _basin_features( "design_flow_m3s": basin.design_flow_m3s, "bridge_required": basin.bridge_required, }, - "geometry": {"type": "Polygon", "coordinates": [ring]}, + "geometry": geometry, } ) for pipe, point in zip(detail.pipes, points): 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_Inflow.py b/B04_PreProcess/B04_PreProcess_Router_Inflow.py index 9d72165a..98ba38f6 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Inflow.py +++ b/B04_PreProcess/B04_PreProcess_Router_Inflow.py @@ -77,19 +77,16 @@ async def _resolve_epsg(project_id: UUID, stored_path: str) -> str: `crs_epsg` 열은 표시용 라벨이라 그 값을 쓰면 좌표가 딴 곳으로 간다 (2026-09-01 실측: 라벨 5179, 실제 5176 — 유입 폴리곤이 1,500km 밖에 찍혔다). """ - from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_crs import resolve_project_crs project_root = Path(resolve_stored_project_path(stored_path)) planned = load_design_route(project_root) if planned is not None and planned.crs_input: return planned.crs_input - prj_crs = project_epsg_from_prj(project_root) - if prj_crs: - return prj_crs pool = get_db_pool() async with pool.acquire() as connection: epsg = await get_surface_crs_epsg(connection, project_id, 0) - return f"EPSG:{epsg or 5186}" + return resolve_project_crs(project_root, db_epsg=epsg) def _collect_inflow( diff --git a/B04_PreProcess/B04_PreProcess_Router_Progress.py b/B04_PreProcess/B04_PreProcess_Router_Progress.py new file mode 100644 index 00000000..58adfd6d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Progress.py @@ -0,0 +1,47 @@ +"""B04 지표면 분석 진행률 파일 입출력. + +`B04_PreProcess_Router` 가 700줄을 넘겨 분리한 조각이다(2026-09-04). 라우터 본체와 +상태 조회 라우터가 **같은 함수**를 써야 해서 여기에 둔다 — 순환 임포트를 피하려고 +양쪽이 이 모듈을 바라본다. 동작·경로·값은 옮기기 전 그대로다. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +logger = logging.getLogger(__name__) + +# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. +PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") + + +def _progress_file_path(project_root: Path) -> Path: + return project_root.joinpath(*PROGRESS_FILE_RELATIVE) + + +def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: + """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" + try: + path = _progress_file_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + {"progress_percent": percent, "current_stage": stage, "message": message}, + ) + except OSError: + logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) + + +def read_surface_progress(project_root: Path) -> dict[str, Any] | None: + """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" + path = _progress_file_path(project_root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None diff --git a/B04_PreProcess/B04_PreProcess_Router_Status.py b/B04_PreProcess/B04_PreProcess_Router_Status.py new file mode 100644 index 00000000..b4461c28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Status.py @@ -0,0 +1,223 @@ +"""B04 지표면 분석 **상태 조회·모델 프리뷰** 라우터. + +`B04_PreProcess_Router` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). 경로·응답·로직은 +옮기기 전 그대로이고, 본체가 `include_router` 로 이 라우터를 그대로 실어 붙인다. +""" + +import logging +from pathlib import Path +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress +from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_initial_snapshot import is_designing +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +# prefix 는 본체(`_Router`)가 `include_router` 할 때 붙는다 — 여기서 또 주면 +# 경로가 `/api/projects/api/projects/...` 로 겹친다. +router = APIRouter(tags=["B04 Surface Analysis"]) + + +@router.get("/{project_id}/surface/status") +async def get_wf1_analysis_status(project_id: UUID) -> dict: + """WF1 분석 상태를 조회한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT state, progress_percent, message, + (SELECT COUNT(*) FROM surface_models + WHERE project_id = %s) as model_count + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + """, + (str(project_id), str(project_id)), + ) + row = await cursor.fetchone() + + # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) + if not row: + await cursor.execute( + """ + SELECT p.status as project_status, COUNT(sm.id) as model_count + FROM projects p + LEFT JOIN surface_models sm ON sm.project_id = p.id + WHERE p.id = %s AND p.deleted_at IS NULL + GROUP BY p.id, p.status + """, + (str(project_id),), + ) + fallback_row = await cursor.fetchone() + if not fallback_row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, + ) + model_count = int(fallback_row["model_count"]) + project_status = str(fallback_row.get("project_status") or "NEW") + + if project_status == "WF1_FAILED": + state = "FAILED" + progress_percent = 0 + message = "WF1 분석에 실패했습니다." + elif model_count > 0 or project_status == "WF1_COMPLETE": + state = "COMPLETE" + progress_percent = 100 + message = "WF1 분석이 완료되었습니다." + elif project_status == "WF1_ANALYZING": + state = "IN_PROGRESS" + progress_percent = 30 + message = "WF1 분석이 진행 중입니다." + else: + state = "NOT_STARTED" + progress_percent = 0 + message = "WF1 분석 대기 중입니다." + else: + state = row["state"] + progress_percent = row["progress_percent"] + message = row["message"] or "" + model_count = int(row["model_count"]) + + if state == "FAILED": + status = "failed" + current_stage = "failed" + if not message: + message = "WF1 분석에 실패했습니다." + elif state == "COMPLETE": + status = "completed" + progress_percent = 100 + current_stage = "completed" + if not message: + message = "WF1 분석이 완료되었습니다." + elif state == "IN_PROGRESS": + status = "in_progress" + current_stage = "surface_analysis" + if not message: + message = "WF1 분석이 진행 중입니다." + # 진행률 파일이 있으면 실제 단계별 진행률로 대체 + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) + if progress: + progress_percent = int(progress.get("progress_percent", progress_percent)) + current_stage = str(progress.get("current_stage", current_stage)) + message = str(progress.get("message", message)) + except LookupError: + pass + else: + status = "pending" + progress_percent = 0 + current_stage = "pending" + if not message: + message = "WF1 분석 대기 중입니다." + + # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 + # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). + # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): + status = "in_progress" + current_stage = "initial_design" + message = "초기 설계를 계산하는 중입니다." + except (LookupError, OSError, ValueError): + pass + + return { + "project_id": str(project_id), + "status": status, + "model_count": model_count, + "progress_percent": progress_percent, + "current_stage": current_stage, + "message": message, + } + except Exception: + logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) +async def get_surface_model_preview( + request: Request, + project_id: UUID, + model_id: int, + smooth: bool = False, +) -> Response | JSONResponse: + """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + model_path = project_root / model_file_path + models_dir = model_path.parent + stem = model_path.stem + + ext = "ply" if model_type == "meshfree" else "glb" + if smooth and model_type in ("dtm", "tin"): + preview_filename = f"{stem}_smooth_preview.glb" + else: + preview_filename = f"{stem}_preview.{ext}" + + preview_path = models_dir / preview_filename + if not preview_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + media_type = "application/octet-stream" + if ext == "glb": + media_type = "model/gltf-binary" + elif ext == "ply": + media_type = "application/ply" + + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) + + except Exception: + logger.exception( + "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, + ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index c5bf9233..9beacc38 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -8,34 +8,41 @@ """ import asyncio -import base64 import json import logging import math +import time from pathlib import Path from typing import Any from uuid import UUID -import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import Point, Polygon, box +from shapely.geometry import Point from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( drainage_dir, - write_grid_arrays, write_stage, ) from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( - AZIMUTH_INVALID, - AZIMUTH_SINK, - AZIMUTH_STEPS, mask_row_spans, ) +from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import ( + _as_polygons, + _boundary_geometry, + _flow_payload, + _grid_bbox_lonlat, + _grid_bbox_polygon, + _line_lonlat, + _polygon_rings, + _write_road_routing, + _write_stage_arrays, +) from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_route_geometry import ( StructureCandidate, find_planned_route_file, @@ -52,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, @@ -166,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)) @@ -190,7 +199,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: ) # 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다. - source_crs = planned.crs_input or f"EPSG:{epsg or 5186}" + source_crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=epsg) to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) @@ -257,7 +266,13 @@ def _route_center_lonlat(stored_path: str, fallback_epsg: int | None) -> tuple[f if planned is None or not planned.vertices: return None middle = planned.vertices[len(planned.vertices) // 2] - source_crs = f"EPSG:{planned.epsg or fallback_epsg or 5186}" + # 여기 좌표는 **원본 파일 그대로**(트림 전)라 파일이 밝힌 좌표계로 읽는다 — 창구 사다리. + source_crs = resolve_project_crs( + Path(resolve_stored_project_path(stored_path)), + route_crs_input=planned.crs_input, + file_label_epsg=planned.epsg, + db_epsg=fallback_epsg, + ) lon, lat = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True).transform( middle.x, middle.y ) @@ -375,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"], @@ -389,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차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. @@ -502,206 +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 - - -def _write_road_routing( - stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any -) -> None: - """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. - - B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 - 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 - 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). - """ - routing = preview.routing - road = preview.road - if routing is None or road is None or road.count == 0: - return - write_grid_arrays( - stored_path, - "road_routing", - spec, - { - "road_slot": routing.road_slot, - "path_length": routing.path_length, - "strength": routing.strength, - "road_cell_index": road.cell_index, - "road_chainage": road.chainage, - "elevation": preview.terrain.elevation.reshape(-1), - }, - { - "road_cells": road.count, - "reached_cells": int((routing.road_slot >= 0).sum()), - "basin_area_m2": round(preview.basin_area_m2, 1), - "pipe_count": len(preview.pipes), - }, - ) - # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. - write_stage( - stored_path, - "road_routing", - { - "route": [route_line], - "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [ - ( - Point(pipe.x, pipe.y), - {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, - ) - for pipe in preview.pipes - ], - # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. - "flow_arrow": [ - ( - Point(x, y), - { - # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. - "x": round(x, 2), - "y": round(y, 2), - "azimuth_deg": round(math.degrees(angle), 1), - "reaches_road": reaches, - "cells": cells, - }, - ) - for x, y, angle, reaches, cells in preview.flow_arrows - ], - }, - { - "basin_area_m2": round(preview.basin_area_m2, 1), - "pipe_count": len(preview.pipes), - "route_length_m": round(route_line.length, 1), - "arrow_count": len(preview.flow_arrows), - "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, - }, - to_lonlat, - ) - - -def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: - """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if domain is not None: - write_grid_arrays( - stored_path, - "primary_region", - spec, - {"mask": domain}, - { - "cells": int(domain.sum()), - "bbox_cells": spec.size, - "expand_rounds": preview.expand_rounds, - "expand_closed": preview.expand_closed, - }, - ) - flow = preview.flow - if flow is None: - return - arrays = { - "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), - "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), - "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), - # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. - "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), - } - if flow.burned is not None: - arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) - if preview.descent is not None: - arrays["band_elevation"] = preview.descent.band_elevation - # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. - if preview.strength_profile: - curve = np.asarray(preview.strength_profile, dtype=np.float64) - arrays["strength_chainage_m"] = curve[:, 0] - arrays["strength_area_m2"] = curve[:, 1] - write_grid_arrays( - stored_path, - "flow_direction", - spec, - arrays, - { - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "analyzed": int(flow.analyzed.sum()), - "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), - "no_road": int((~flow.reaches_road & flow.analyzed).sum()), - "burned": 0 if flow.burned is None else int(flow.burned.sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "strength_points": len(preview.strength_profile), - "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), - }, - ) - - -def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: - """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. - - 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: - 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 - 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). - 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. - """ - flow = preview.flow - if flow is None or domain is None: - return None - order = np.flatnonzero(domain.reshape(-1)) - analyzed = flow.analyzed[order] - reaches = flow.reaches_road[order] - packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) - packed |= np.where(reaches, 0x80, 0).astype(np.uint8) - burned = flow.burned - return { - "encoding": "base64-uint8", - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "cells": int(order.size), - "reaches_road": int((reaches & analyzed).sum()), - "no_road": int((~reaches & analyzed).sum()), - # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. - "unanalyzed": int((~analyzed).sum()), - # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. - "burned": 0 if burned is None else int(burned[order].sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "data": base64.b64encode(packed.tobytes()).decode("ascii"), - } - - -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - -def _as_polygons(geometry: Any) -> list[Any]: - if geometry is None or geometry.is_empty: - return [] - return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] - - -def _grid_bbox_polygon(spec: Any) -> Polygon: - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_min = spec.y_max - spec.n_rows * spec.cell_m - return box(spec.x_min, y_min, x_max, spec.y_max) - - -def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: - return [list(to_lonlat(x, y)) for x, y in line.coords] - - -def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: - """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" - if geometry is None or geometry.is_empty: - return [] - parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] - return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] - - -def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: - x_min = spec.x_min - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_max = spec.y_max - y_min = spec.y_max - spec.n_rows * spec.cell_m - corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) - return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py new file mode 100644 index 00000000..87affee5 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py @@ -0,0 +1,225 @@ +"""배수유역 분석 **산출물 기록·응답 기하** 헬퍼. + +`B04_PreProcess_Router_Watershed` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). +B05 가 읽을 `.npz`·스테이지 기하를 남기는 쓰기 함수와, 응답용 좌표 변환 헬퍼만 모았다. +함수 본문·값은 옮기기 전 그대로이고, 라우터가 이 모듈에서 가져다 쓴다. +""" + +import base64 +import math +from typing import Any + +import numpy as np +from shapely.geometry import Point, Polygon, box + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( + write_grid_arrays, + write_stage, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, +) +from config.config_system import DRAINAGE_ARROW_SPACING_M + + +def _write_road_routing( + stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any +) -> None: + """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. + + B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 + 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 + 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). + """ + routing = preview.routing + road = preview.road + if routing is None or road is None or road.count == 0: + return + write_grid_arrays( + stored_path, + "road_routing", + spec, + { + "road_slot": routing.road_slot, + "path_length": routing.path_length, + "strength": routing.strength, + "road_cell_index": road.cell_index, + "road_chainage": road.chainage, + "elevation": preview.terrain.elevation.reshape(-1), + }, + { + "road_cells": road.count, + "reached_cells": int((routing.road_slot >= 0).sum()), + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + }, + ) + # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. + write_stage( + stored_path, + "road_routing", + { + "route": [route_line], + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, + ) + for pipe in preview.pipes + ], + # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. + "flow_arrow": [ + ( + Point(x, y), + { + # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. + "x": round(x, 2), + "y": round(y, 2), + "azimuth_deg": round(math.degrees(angle), 1), + "reaches_road": reaches, + "cells": cells, + }, + ) + for x, y, angle, reaches, cells in preview.flow_arrows + ], + }, + { + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + "route_length_m": round(route_line.length, 1), + "arrow_count": len(preview.flow_arrows), + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, + }, + to_lonlat, + ) + + +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: + """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" + if domain is not None: + write_grid_arrays( + stored_path, + "primary_region", + spec, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, + ) + flow = preview.flow + if flow is None: + return + arrays = { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. + "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), + } + if flow.burned is not None: + arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) + if preview.descent is not None: + arrays["band_elevation"] = preview.descent.band_elevation + # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. + if preview.strength_profile: + curve = np.asarray(preview.strength_profile, dtype=np.float64) + arrays["strength_chainage_m"] = curve[:, 0] + arrays["strength_area_m2"] = curve[:, 1] + write_grid_arrays( + stored_path, + "flow_direction", + spec, + arrays, + { + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "analyzed": int(flow.analyzed.sum()), + "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), + "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "burned": 0 if flow.burned is None else int(flow.burned.sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "strength_points": len(preview.strength_profile), + "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), + }, + ) + + +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: + """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. + + 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: + 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). + 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. + """ + flow = preview.flow + if flow is None or domain is None: + return None + order = np.flatnonzero(domain.reshape(-1)) + analyzed = flow.analyzed[order] + reaches = flow.reaches_road[order] + packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) + packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + burned = flow.burned + return { + "encoding": "base64-uint8", + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "cells": int(order.size), + "reaches_road": int((reaches & analyzed).sum()), + "no_road": int((~reaches & analyzed).sum()), + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. + "unanalyzed": int((~analyzed).sum()), + # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. + "burned": 0 if burned is None else int(burned[order].sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "data": base64.b64encode(packed.tobytes()).decode("ascii"), + } + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + + +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 716806e6..251369cf 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -15,6 +15,7 @@ * 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다. * ========================================================================== */ +import { pointInRings } from "./B04_PreProcess_UI_MapOverlays"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; @@ -71,17 +72,6 @@ const BASIN_ALPHA_PLAIN = 0.22; const BASIN_ALPHA_SELECTED = 0.38; const BASIN_ALPHA_MUTED = 0.06; -/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */ -function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let inside = false; - for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { - const [xi, yi] = ring[index]; - const [xj, yj] = ring[previous]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; - } - return inside; -} - interface PipeMarker { chainage: number; source: PipeSource; @@ -289,10 +279,11 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl let smallest = Number.POSITIVE_INFINITY; basins.forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; - const ring = basin.polygon_lonlat.map(([lon, lat]) => - lonLatToScreen(normalizerRef as Normalizer, view, lon, lat), + // 구멍 안(= 안에 든 다른 유역)을 누르면 바깥 유역이 잡히지 않는다. + const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) => + ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)), ); - if (!pointInRing(ring, x, y)) return; + if (!pointInRings(rings, x, y)) return; if (basin.area_m2 < smallest) { smallest = basin.area_m2; hit = basin.index; @@ -508,12 +499,18 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl const ring = basin.polygon_lonlat.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat), ); + // 조각·구멍을 한 경로에 담아 even-odd로 채운다 — 구멍이 실제로 뚫린다. + const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((part) => + part.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)), + ); context.beginPath(); - ring.forEach(([px, py], order) => { - if (order === 0) context.moveTo(px, py); - else context.lineTo(px, py); + rings.forEach((part) => { + part.forEach(([px, py], order) => { + if (order === 0) context.moveTo(px, py); + else context.lineTo(px, py); + }); + context.closePath(); }); - context.closePath(); // 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게. const muted = selectedBasin !== null && selectedBasin !== basin.index; const alpha = muted @@ -522,7 +519,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl ? BASIN_ALPHA_SELECTED : BASIN_ALPHA_PLAIN; context.fillStyle = basinColor(index, alpha); - context.fill(); + context.fill("evenodd"); context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95); context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8; context.stroke(); diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 7a40a3a5..b6c20751 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -120,7 +120,11 @@ function pivotReticleTexture(): THREE.CanvasTexture { } export interface CursorPivotOptions { - camera: THREE.PerspectiveCamera | THREE.OrthographicCamera; + /** 카메라. **바뀔 수 있으면 함수로** 준다 — B05는 직교/원근을 갈아 끼운다(2026-09-04). */ + camera: + | THREE.PerspectiveCamera + | THREE.OrthographicCamera + | (() => THREE.PerspectiveCamera | THREE.OrthographicCamera); controls: OrbitControls; /** 포인터 이벤트를 받는 캔버스. */ element: HTMLElement; @@ -134,7 +138,9 @@ export interface CursorPivotOptions { /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { - const { camera, controls, element } = options; + const { controls, element } = options; + const getCamera = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => + typeof options.camera === "function" ? options.camera() : options.camera; // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). controls.enableRotate = false; controls.enableZoom = false; @@ -165,6 +171,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void /** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */ function syncPivotMarker(): void { + const camera = getCamera(); if (!pivotMarker || !pivotMarker.visible) return; pivotMarker.position.copy(pivot); const distance = camera.position.distanceTo(pivot); @@ -190,6 +197,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ function pickPivot(event: { clientX: number; clientY: number }): void { + const camera = getCamera(); pivot.copy(controls.target); const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; @@ -219,7 +227,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. pickPivot(event); panAnchor.copy(pivot); - camera.getWorldDirection(viewDirection); + getCamera().getWorldDirection(viewDirection); panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor); panPointerId = event.pointerId; element.setPointerCapture?.(event.pointerId); @@ -243,6 +251,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. * 휠을 위로 올리면 멀어진다(사용자 지시). */ function onWheel(event: WheelEvent): void { + const camera = getCamera(); if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); pickPivot(event); @@ -265,6 +274,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void } function onPointerMove(event: PointerEvent): void { + const camera = getCamera(); if (panPointerId === event.pointerId) { // 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다. const rect = element.getBoundingClientRect(); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 4b9867a1..4ef0f6a6 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; +import { stationLabel } from "@util/common_util_svg"; /** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */ const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)"); @@ -19,6 +20,11 @@ import { export type FilledRing = { ring: ReadonlyArray; + /** + * 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** — + * 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만. + */ + rings?: ReadonlyArray>; /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ label?: string; }; @@ -35,19 +41,27 @@ export function drawFilledRing( color: string, ): void { if (entry.ring.length < 3) return; + const rings = entry.rings?.length ? entry.rings : [entry.ring]; let sumX = 0; let sumY = 0; context.beginPath(); - entry.ring.forEach(([lon, lat], index) => { + rings.forEach((ring) => { + if (ring.length < 3) return; + ring.forEach(([lon, lat], index) => { + const [x, y] = lonLatToScreen(normalizer, view, lon, lat); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.closePath(); + }); + // 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다. + entry.ring.forEach(([lon, lat]) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); sumX += x; sumY += y; - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); }); - context.closePath(); context.fillStyle = color; - context.fill(); + context.fill("evenodd"); context.strokeStyle = color; context.lineWidth = 1.6; context.stroke(); @@ -87,6 +101,26 @@ export function drawRingBadge( } /** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */ +/** + * 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정. + * 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다. + */ +export function pointInRings( + rings: ReadonlyArray>, + x: number, + y: number, +): boolean { + let inside = false; + for (const ring of rings) { + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { + const [xi, yi] = ring[index]; + const [xj, yj] = ring[previous]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + } + return inside; +} + export function ringCenterOnScreen( ring: ReadonlyArray, normalizer: Normalizer, @@ -151,3 +185,100 @@ export function drawRidgeRing( context.stroke(); context.restore(); } + +/* ----------------------------------------------------------------------------- + * 계획선 위 측점 눈금·번호 (2026-09-04 사용자 지시) + * + * 종단·3D와 같은 `측점번호+잔여거리` 표기다. 배율이 낮으면 글자가 붙으므로 3D 라벨과 같은 + * 단계 규칙으로 솎는다(5칸 → 2칸 → 전부). 관 마커가 있는 측점은 라벨을 계획선 **반대쪽** + * 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다. + * -------------------------------------------------------------------------- */ + +export interface StationTickOptions { + /** 규칙 측점 간격(m). */ + intervalM: number; + /** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */ + pxPerMeter: number; + toScreen: (x: number, y: number) => [number, number]; + /** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */ + avoidChainages?: ReadonlyArray; +} + +export function drawStationTicks( + context: CanvasRenderingContext2D, + points: ReadonlyArray<{ x: number; y: number }>, + options: StationTickOptions, +): void { + if (points.length < 2) return; + const interval = options.intervalM > 0 ? options.intervalM : 20; + // 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다. + const gapPx = interval * options.pxPerMeter; + const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5; + const avoid = options.avoidChainages ?? []; + + // 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다. + const cumulative: number[] = [0]; + for (let index = 1; index < points.length; index += 1) { + cumulative.push( + cumulative[index - 1] + + Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y), + ); + } + const total = cumulative[cumulative.length - 1]; + if (total <= 0) return; + + context.save(); + context.font = "11px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + // 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과 + // 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다). + const drawn: Array<{ x: number; y: number; half: number }> = []; + let cursor = 1; + for (let chainage = 0; chainage <= total; chainage += interval) { + const stationNo = Math.round(chainage / interval); + if (stationNo % step !== 0) continue; + while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; + const back = points[cursor - 1]; + const front = points[cursor]; + const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; + const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); + const px = back.x + (front.x - back.x) * ratio; + const py = back.y + (front.y - back.y) * ratio; + const [sx, sy] = options.toScreen(px, py); + const [bx, by] = options.toScreen(back.x, back.y); + const [fx, fy] = options.toScreen(front.x, front.y); + const dx = fx - bx; + const dy = fy - by; + const length = Math.hypot(dx, dy) || 1; + // 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다. + const ux = -dy / length; + const uy = dx / length; + const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2); + const side = nearPipe ? -1 : 1; + + context.beginPath(); + context.moveTo(sx - ux * 6, sy - uy * 6); + context.lineTo(sx + ux * 6, sy + uy * 6); + context.lineWidth = 1.2; + context.strokeStyle = "rgba(40, 40, 40, 0.85)"; + context.stroke(); + + const label = stationLabel(chainage, interval); + const lx = sx + ux * side * 16; + const ly = sy + uy * side * 16; + const width = context.measureText(label).width + 6; + const half = width / 2; + const collides = drawn.some( + (item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16, + ); + if (collides) continue; + drawn.push({ x: lx, y: ly, half }); + // 배경을 깔아 등고선 위에서도 읽히게 한다. + context.fillStyle = "rgba(255, 255, 255, 0.78)"; + context.fillRect(lx - half, ly - 8, width, 16); + context.fillStyle = "#222222"; + context.fillText(label, lx, ly); + } + context.restore(); +} diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 9a56bf87..258b9723 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -121,6 +121,59 @@ export function computeMapRect(meta: VWorldMeta | null, width: number, height: n * (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */ export const ROUTE_VIEW_MARGIN_M = 200; +/** + * 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용). + * + * 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다 + * (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다. + */ +export function createMetricProjector( + meta: VWorldMeta, + view: ViewState, +): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } { + const spanX = meta.width_meters || 1; + const spanY = meta.height_meters || 1; + const ax = view.mapRect.width * view.scale; + const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; + const ay = view.mapRect.height * view.scale; + const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; + return { + toScreen: (x, y) => [ + ((x - meta.x_min) / spanX) * ax + bx, + (1 - (y - meta.y_min) / spanY) * ay + by, + ], + pxPerMeter: ax / spanX, + }; +} + +/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */ +export const MAP_STATION_INTERVAL_M = 20; + +/** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점 + * (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */ +export const MAX_ZOOM_VIEW_WIDTH_M = 20; + +/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */ +export const ZOOM_SCALE_HARD_CAP = 2000; + +/** + * 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다. + * + * 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로, + * 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다. + * 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다. + */ +export function computeMaxScale( + meta: VWorldMeta | null, + mapRectWidth: number, + viewportWidth: number, + fallback: number, +): number { + if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback; + const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M); + return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale)); +} + /** 평면 좌표(m) 범위. */ export interface PlanBounds { x_min: number; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index e7403d3f..61782992 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -26,7 +26,10 @@ import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength"; import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed"; import { computeMapRect, + computeMaxScale, computeRouteView, + createMetricProjector, + MAP_STATION_INTERVAL_M, createNormalizer, drawPreparedLabels, drawPreparedLayer, @@ -41,6 +44,7 @@ import { type PreparedLayer, type ViewState, } from "./B04_PreProcess_UI_MapRender"; +import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays"; import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch"; export interface SurfaceMapViewer { @@ -160,8 +164,10 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { ); const activeGisLayers = new Set(GIS_LAYERS.filter((layer) => GIS_DEFAULT_ON[layer])); let showContourLabels = CONTOUR_LABEL_DEFAULT_ON; - // 계획선(B03 업로드 계획노선) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다. + // 계획선(B03 계획노선 정본) — 사업지 좌표계(m) 폴리라인을 배경 지도 위에 겹친다. let routeLayer: PreparedLayer | null = null; + // 측점 눈금·번호를 찍기 위한 원본 점 목록 (2026-09-04 사용자 지시). + let routePoints: ReadonlyArray<{ x: number; y: number }> = []; let showRoute = true; let scale = 1; let offsetX = 0; @@ -314,6 +320,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { function updateImageTransform(): void { backgroundImages.forEach((image) => { image.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + // 크게 당기면 배경 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다 + // (2026-09-04 사용자 지시). 실제 크기는 축척 막대로 읽는다. + image.style.imageRendering = scale > 4 ? "pixelated" : "auto"; }); } @@ -416,6 +425,15 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { context.strokeStyle = routeLineColor(); drawPreparedLayer(context, routeLayer, view, "dot"); } + // 측점 눈금·번호 — 계획선 위, 유역 오버레이 아래. B05 배수유역도와 같은 규칙이다. + if (showRoute && meta && routePoints.length > 1) { + const projector = createMetricProjector(meta, view); + drawStationTicks(context, routePoints, { + intervalM: MAP_STATION_INTERVAL_M, + pxPerMeter: projector.pxPerMeter, + toScreen: projector.toScreen, + }); + } // 흐름 강도(도로 색·유입 집중점 마커)는 계획선 위에 얹는다. flowStrength.draw(context, normalizer, view); // 세부유역 채움과 관 마커는 그 위 — 편집 대상이라 다른 레이어에 가려지면 집을 수 없다. @@ -443,6 +461,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { meta = null; preparedLayers.clear(); routeLayer = null; + routePoints = []; resetView(); status.textContent = L("B04_Surface_Map_Loading"); showProgress(0, L("B04_Surface_Map_Loading")); @@ -480,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { normalizer = createNormalizer(nextMeta); routeLayer = planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null; + routePoints = planned.points; // 흐름 강도는 계획선 위에 칠하므로 같은 점 목록·같은 메타를 쓴다(어긋나면 색이 밀린다). flowStrength.setRoute(planned.points, nextMeta); // 관 마커도 같은 계획선 위에 스냅한다 — 목록이 다르면 마커가 노선을 벗어난다. @@ -518,7 +538,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { event.preventDefault(); const prevScale = scale; // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). 일반 스크롤과 반대 방향이다. - scale = Math.min(8, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); + // 상한은 「화면 폭 20m」로 계산한다 — 도엽 크기가 달라도 체감이 같다(2026-09-04 사용자 지시). + const wheelRect = viewport.getBoundingClientRect(); + const wheelWidth = Math.max(1, Math.floor(wheelRect.width)); + const maxScale = computeMaxScale( + meta, + computeMapRect(meta, wheelWidth, Math.max(1, Math.floor(wheelRect.height))).width, + wheelWidth, + 8, + ); + scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87))); // 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정. // screen = center + (base - center)·scale + offset 이므로, // 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale). diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 300c8495..6b97dd5e 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -39,6 +39,8 @@ import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer"; import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer"; import "./B04_PreProcess_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "./B04_PreProcess_UI_Style_Map.css"; // 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가 // 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정). diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 0ee38188..3ac47607 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -557,307 +557,9 @@ text-shadow: 0 1px 2px var(--color-surface-raised); } -/* --- 하단 2D 지도 --- */ -.b04-map { - --b04-map-vector: var(--color-accent); - display: flex; - flex-direction: column; - gap: var(--spacing-12); - margin: var(--spacing-24); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); -} - -.b04-map__header, -.b04-map__controls, -.b04-map__control-group, -.b04-map__layer-buttons { - display: flex; - align-items: center; -} - -.b04-map__header { - justify-content: space-between; - gap: var(--spacing-16); -} - -.b04-map__header h3 { - font-size: var(--text-body); - color: var(--color-text); -} - -.b04-map__controls { - gap: var(--spacing-12); - flex-wrap: wrap; -} - -.b04-map__control-group { - gap: var(--spacing-8); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b04-map__layer-buttons { - gap: var(--spacing-4); - flex-wrap: wrap; -} - -.b04-map__controls button { - min-height: 34px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-canvas); - color: var(--color-text-body); -} - -.b04-map__controls button { - cursor: pointer; -} - -.b04-map__controls .b04-map__layer-button { - padding: var(--spacing-8); - opacity: 0.55; -} - -.b04-map__layer-button.is-active { - opacity: 1; - box-shadow: inset 0 0 0 1px currentColor; -} - -.b04-map__layer-button--gis { - border-color: var(--color-border); - color: var(--color-text-secondary); -} - -.b04-map__layer-button--gis.is-active { - border-color: var(--b04-layer-color); - color: var(--b04-layer-color); - box-shadow: inset 0 0 0 1px var(--b04-layer-color); -} - -/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ -.b04-map__context-menu { - position: absolute; - z-index: 6; - display: flex; - flex-direction: column; - min-width: 140px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b04-map__context-menu[hidden] { - display: none; -} - -.b04-map__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b04-map__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -.b04-map__viewport { - position: relative; - width: 100%; - height: 560px; - overflow: hidden; - touch-action: none; - /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 - JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ - cursor: default; - user-select: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-canvas); -} - -.b04-map__image, -.b04-map__canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.b04-map__image { - object-fit: contain; - transform-origin: center; - user-select: none; - pointer-events: none; -} - -.b04-map__canvas { - pointer-events: none; -} - -.b04-map__empty { - position: absolute; - z-index: 2; - inset: 50% auto auto 50%; - transform: translate(-50%, -50%); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). - 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ -.b04-map__status-stack { - position: absolute; - z-index: 2; - top: var(--spacing-12); - left: var(--spacing-12); - display: flex; - max-width: min(62%, 900px); - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 배수유역 "분석 중…" 진행 문구 자리 — 우측 최상단(2026-08-01 사용자 지시). - 결과 문구는 좌상단에 그대로 남는다. */ -.b04-map__status-topright { - position: absolute; - z-index: 2; - top: var(--spacing-12); - right: var(--spacing-12); - display: flex; - max-width: min(40%, 360px); - flex-direction: column; - align-items: flex-end; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). - 축척은 좌하단이라 서로 겹치지 않는다. */ -.b04-map__status-corner { - position: absolute; - z-index: 2; - right: var(--spacing-12); - bottom: var(--spacing-12); - display: flex; - max-width: min(50%, 480px); - flex-direction: column; - align-items: flex-end; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 - 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. - 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ -.b04-map__status, -.b04-map__watershed-status { - display: block; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); - color: var(--color-text-body); - font-size: var(--text-caption); - line-height: 1.5; - word-break: keep-all; - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} - -.b04-map__watershed-status[hidden] { - display: none; -} - -@media (max-width: 760px) { - .b04-map__header { - align-items: flex-start; - flex-direction: column; - } -} - -@media (max-width: 1180px) { - .b04-surface__viewers { - grid-template-columns: 1fr; - } -} - -/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ -.b04-surface__sheet-section { - margin: 0 var(--spacing-24) var(--spacing-16); - padding: var(--spacing-16); - box-sizing: border-box; -} - -.b04-surface__sheet-section > .terrain-model-group { - margin-top: var(--spacing-12); -} - -/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ -.b04-surface__sheet-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - margin: var(--spacing-12) 0; -} - -.b04-surface__sheet-method { - padding: 4px 10px; - font-size: var(--text-caption); - color: var(--color-text-body); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - cursor: pointer; -} - -.b04-surface__sheet-method:hover { - border-color: var(--color-primary); -} - -.b04-surface__sheet-method.is-active { - color: var(--color-on-primary, #fff); - background: var(--color-primary); - border-color: var(--color-primary); -} - -/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ -.b04-surface__sheet-method.is-unbuilt:not(.is-active) { - color: var(--color-text-muted, var(--color-text-body)); - border-style: dashed; -} - -.b04-surface__sheet-lidar { - margin-left: auto; -} - -/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */ -.b04-surface__sheet-smoothing { - margin-left: auto; - display: flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-caption); -} - -.b04-surface__sheet-smoothing .b04-surface__select { - width: auto; - min-width: 96px; -} - -.b04-surface__sheet-toolbar .b04-surface__sheet-lidar { - margin-left: 0; +/* 방위 콤파스를 놓을 자리 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. + 위젯 모양·색은 공용(`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */ +.b04-surface__compass { + right: var(--spacing-16); + bottom: var(--spacing-16); } diff --git a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css new file mode 100644 index 00000000..b2f0bc28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css @@ -0,0 +1,312 @@ +/* ============================================================================= + * B04_PreProcess_UI_Style_Map.css + * 1차 워크플로우 하단 **2D 지도**와 그 도구줄·시트 툴바. + * + * `B04_PreProcess_UI_Style.css` 가 700줄을 넘겨 잘라낸 조각이다(2026-09-04). + * 규칙·순서·값 그대로 옮겼고, 진입 TS 가 본체 다음에 불러 캐스케이드 순서도 같다. + * ========================================================================== */ + +/* --- 하단 2D 지도 --- */ +.b04-map { + --b04-map-vector: var(--color-accent); + display: flex; + flex-direction: column; + gap: var(--spacing-12); + margin: var(--spacing-24); + padding: var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.b04-map__header, +.b04-map__controls, +.b04-map__control-group, +.b04-map__layer-buttons { + display: flex; + align-items: center; +} + +.b04-map__header { + justify-content: space-between; + gap: var(--spacing-16); +} + +.b04-map__header h3 { + font-size: var(--text-body); + color: var(--color-text); +} + +.b04-map__controls { + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.b04-map__control-group { + gap: var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b04-map__layer-buttons { + gap: var(--spacing-4); + flex-wrap: wrap; +} + +.b04-map__controls button { + min-height: 34px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-canvas); + color: var(--color-text-body); +} + +.b04-map__controls button { + cursor: pointer; +} + +.b04-map__controls .b04-map__layer-button { + padding: var(--spacing-8); + opacity: 0.55; +} + +.b04-map__layer-button.is-active { + opacity: 1; + box-shadow: inset 0 0 0 1px currentColor; +} + +.b04-map__layer-button--gis { + border-color: var(--color-border); + color: var(--color-text-secondary); +} + +.b04-map__layer-button--gis.is-active { + border-color: var(--b04-layer-color); + color: var(--b04-layer-color); + box-shadow: inset 0 0 0 1px var(--b04-layer-color); +} + +/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ +.b04-map__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 140px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b04-map__context-menu[hidden] { + display: none; +} + +.b04-map__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b04-map__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +.b04-map__viewport { + position: relative; + width: 100%; + height: 560px; + overflow: hidden; + touch-action: none; + /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 + JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-canvas); +} + +.b04-map__image, +.b04-map__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b04-map__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b04-map__canvas { + pointer-events: none; +} + +.b04-map__empty { + position: absolute; + z-index: 2; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). + 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ +.b04-map__status-stack { + position: absolute; + z-index: 2; + top: var(--spacing-12); + left: var(--spacing-12); + display: flex; + max-width: min(62%, 900px); + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 "분석 중…" 진행 문구 자리 — 우측 최상단(2026-08-01 사용자 지시). + 결과 문구는 좌상단에 그대로 남는다. */ +.b04-map__status-topright { + position: absolute; + z-index: 2; + top: var(--spacing-12); + right: var(--spacing-12); + display: flex; + max-width: min(40%, 360px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). + 축척은 좌하단이라 서로 겹치지 않는다. */ +.b04-map__status-corner { + position: absolute; + z-index: 2; + right: var(--spacing-12); + bottom: var(--spacing-12); + display: flex; + max-width: min(50%, 480px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 + 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. + 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ +.b04-map__status, +.b04-map__watershed-status { + display: block; + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.5; + word-break: keep-all; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.b04-map__watershed-status[hidden] { + display: none; +} + +@media (max-width: 760px) { + .b04-map__header { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 1180px) { + .b04-surface__viewers { + grid-template-columns: 1fr; + } +} + +/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ +.b04-surface__sheet-section { + margin: 0 var(--spacing-24) var(--spacing-16); + padding: var(--spacing-16); + box-sizing: border-box; +} + +.b04-surface__sheet-section > .terrain-model-group { + margin-top: var(--spacing-12); +} + +/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ +.b04-surface__sheet-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-12) 0; +} + +.b04-surface__sheet-method { + padding: 4px 10px; + font-size: var(--text-caption); + color: var(--color-text-body); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + cursor: pointer; +} + +.b04-surface__sheet-method:hover { + border-color: var(--color-primary); +} + +.b04-surface__sheet-method.is-active { + color: var(--color-on-primary, #fff); + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ +.b04-surface__sheet-method.is-unbuilt:not(.is-active) { + color: var(--color-text-muted, var(--color-text-body)); + border-style: dashed; +} + +.b04-surface__sheet-lidar { + margin-left: auto; +} + +/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */ +.b04-surface__sheet-smoothing { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-caption); +} + +.b04-surface__sheet-smoothing .b04-surface__select { + width: auto; + min-width: 96px; +} + +.b04-surface__sheet-toolbar .b04-surface__sheet-lidar { + margin-left: 0; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 75b7c565..ad92027c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -3,10 +3,11 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createProgressCircle } from "@ui/ui_template_progress"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. +import { buildTerrainViewerChrome } from "./B04_PreProcess_UI_TerrainViewer_Chrome"; +import { loadContourLinesInto } from "./B04_PreProcess_UI_TerrainViewer_Contours"; import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { @@ -20,10 +21,6 @@ import { type SurfaceCameraState, } from "./B04_PreProcess_UI_Camera"; -/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서 - * 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */ -const MAX_CONTOUR_LABELS = 40; - function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -55,162 +52,32 @@ export interface SurfaceTerrainViewer { } export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { - const root = document.createElement("div"); - root.className = "terrain-model-group"; - - const statusSpan = document.createElement("span"); - statusSpan.className = "terrain-status"; - statusSpan.style.fontSize = "var(--text-caption)"; - statusSpan.style.color = "var(--color-text-secondary)"; - statusSpan.textContent = "모델 선택 대기 중..."; + // DOM 뼈대는 700줄 제한으로 `_TerrainViewer_Chrome` 로 옮겼다 — 이름·순서는 그대로다. + const { + root, + statusSpan, + axesCheck, + surfCheck, + smoothLabel, + smoothSelect, + contourCheck, + intervalForm, + intervalInput, + intervalSubmit, + optionsContent, + viewerArea, + canvas, + scaleBar, + scaleLabel, + compass, + legendBar, + maxValSpan, + minValSpan, + progress, + } = buildTerrainViewerChrome(); let activeFilter = "csf"; let activeMethod = "dtm"; - const axesCheck = document.createElement("input"); - axesCheck.type = "checkbox"; - axesCheck.checked = false; - - const rightControls = document.createElement("div"); - rightControls.className = "viewer-options model-display-options"; - - // Surface Toggle - const surfLabel = document.createElement("label"); - surfLabel.className = "toggle-label toggle-button"; - const surfCheck = document.createElement("input"); - surfCheck.type = "checkbox"; - surfCheck.checked = true; - surfLabel.append(surfCheck, document.createTextNode(" 서피스")); - - // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. - // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). - // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b04-surface__field"; - const smoothCaption = document.createElement("span"); - smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); - const smoothSelect = document.createElement("select"); - smoothSelect.className = "b04-surface__select"; - ( - [ - ["on", "B04_Surface_Smoothing_On"], - ["off", "B04_Surface_Smoothing_Off"], - ] as const - ).forEach(([value, key]) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = L(key); - smoothSelect.append(option); - }); - smoothSelect.value = "on"; - smoothLabel.append(smoothCaption, smoothSelect); - - // Contour Toggle - const contourLabel = document.createElement("label"); - contourLabel.className = "toggle-label toggle-button"; - const contourCheck = document.createElement("input"); - contourCheck.type = "checkbox"; - contourCheck.checked = true; - contourLabel.append(contourCheck, document.createTextNode(" 등고선")); - - // Contour Interval input form - const intervalForm = document.createElement("form"); - intervalForm.className = "contour-interval-form"; - - const intervalInput = document.createElement("input"); - intervalInput.type = "number"; - intervalInput.value = "1.0"; - intervalInput.step = "0.5"; - intervalInput.min = "0.5"; - intervalInput.className = "contour-interval-input"; - - const intervalSubmit = document.createElement("button"); - intervalSubmit.type = "submit"; - intervalSubmit.textContent = "적용"; - intervalSubmit.className = "contour-interval-submit"; - - intervalForm.append( - document.createTextNode("간격 "), - intervalInput, - document.createTextNode("m "), - intervalSubmit, - ); - - const axesLabel = document.createElement("label"); - axesLabel.className = "toggle-label toggle-button"; - axesLabel.append(axesCheck, document.createTextNode(" 축")); - rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); - - // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. - const optionsContent = document.createElement("div"); - optionsContent.className = "terrain-options-content"; - optionsContent.append(rightControls, statusSpan); - - // 3D View container - const viewerArea = document.createElement("div"); - viewerArea.className = "three-viewer"; - viewerArea.style.position = "relative"; - viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; - viewerArea.style.overflow = "hidden"; - - const canvas = document.createElement("canvas"); - canvas.className = "b04-surface-viewer__canvas"; - viewerArea.append(canvas); - root.append(viewerArea); - - // Scale bar overlay - const scaleBar = document.createElement("div"); - scaleBar.className = "b04-surface__scale"; - scaleBar.hidden = true; - - const scaleLabel = document.createElement("span"); - scaleBar.append(scaleLabel); - viewerArea.append(scaleBar); - - // Elevation bounds legend bar overlay (I-403) - const legendBar = document.createElement("div"); - legendBar.style.position = "absolute"; - legendBar.style.top = "16px"; - legendBar.style.right = "16px"; - legendBar.style.background = "rgba(255, 255, 255, 0.9)"; - legendBar.style.border = "1px solid #cbd5e1"; - legendBar.style.borderRadius = "6px"; - legendBar.style.padding = "8px"; - legendBar.style.width = "50px"; - legendBar.style.display = "none"; // hidden until contours are loaded - legendBar.style.flexDirection = "column"; - legendBar.style.alignItems = "center"; - legendBar.style.zIndex = "10"; - legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; - legendBar.style.pointerEvents = "none"; - - const maxValSpan = document.createElement("span"); - maxValSpan.style.fontSize = "10px"; - maxValSpan.style.fontWeight = "bold"; - maxValSpan.style.color = "#b91c1c"; - maxValSpan.style.marginBottom = "4px"; - - const gradientDiv = document.createElement("div"); - gradientDiv.style.width = "12px"; - gradientDiv.style.height = "120px"; - gradientDiv.style.background = - "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; - gradientDiv.style.borderRadius = "2px"; - gradientDiv.style.border = "1px solid #94a3b8"; - - const minValSpan = document.createElement("span"); - minValSpan.style.fontSize = "10px"; - minValSpan.style.fontWeight = "bold"; - minValSpan.style.color = "#1d4ed8"; - minValSpan.style.marginTop = "4px"; - - legendBar.append(maxValSpan, gradientDiv, minValSpan); - viewerArea.append(legendBar); - - // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. - const progress = createProgressCircle({ overlay: true }); - progress.root.hidden = true; - viewerArea.append(progress.root); - /** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */ function showProgress(ratio: number | null, label: string | null): void { progress.root.hidden = label === null; @@ -595,157 +462,35 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } - async function loadContourLines( + /** 등고선 적재는 700줄 제한으로 `_TerrainViewer_Contours` 로 옮겼다 — 호출부는 그대로다. */ + const loadContourLines = ( modelId: number, isSmooth: boolean, recalculate = false, - ): Promise { - const interval = parseFloat(intervalInput.value) || 1.0; - const projectId = currentProjectId; - const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; - - try { - // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. - const data = await fetchCachedJson(projectId, contourUrl); - if ( - currentProjectId !== projectId || - currentModelId !== modelId || - currentModelSmooth !== isSmooth || - (parseFloat(intervalInput.value) || 1.0) !== interval - ) { - return false; - } - - clearContours(); - - const bounds = data.bounds; - if (!bounds) return false; - - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - const cz = (bounds.z[0] + bounds.z[1]) / 2; - - const transform = (coords: [number, number, number][]) => { - return coords.map(([x_model, y_model, z_model]) => { - const x_scene = x_model - cx; - const y_scene = z_model - cz; - const z_scene = -(y_model - cy); - return new THREE.Vector3(x_scene, y_scene, z_scene); - }); - }; - - let minH = Infinity; - let maxH = -Infinity; - // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. - // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). - const majorPoints: THREE.Vector3[] = []; - const minorPoints: THREE.Vector3[] = []; - const labelCandidates: { - level: number; - position: THREE.Vector3; - length: number; - }[] = []; - - data.contours.forEach((c: any) => { - if (c.level < minH) minH = c.level; - if (c.level > maxH) maxH = c.level; - - const points = transform(c.coordinates); - if (points.length < 2) return; - - const isMajor = c.level % (interval * 5) === 0; - const bucket = isMajor ? majorPoints : minorPoints; - for (let i = 0; i < points.length - 1; i++) { - bucket.push(points[i], points[i + 1]); - } - - // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 - // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 - // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. - if (isMajor && points.length > 4) { - let length = 0; - for (let i = 0; i < points.length - 1; i++) { - length += points[i].distanceTo(points[i + 1]); - } - labelCandidates.push({ - level: c.level, - position: points[Math.floor(points.length / 2)], - length, - }); - } - }); - - labelCandidates.sort((a, b) => b.length - a.length); - for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { - const labelPos = candidate.position; - const labelDiv = document.createElement("div"); - labelDiv.className = "contour-label"; - labelDiv.innerText = `${Math.round(candidate.level)}m`; - labelDiv.style.position = "absolute"; - labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; - labelDiv.style.border = "1px solid #d97706"; - labelDiv.style.color = "#b45309"; - labelDiv.style.padding = "1px 4px"; - labelDiv.style.borderRadius = "3px"; - labelDiv.style.fontSize = "9px"; - labelDiv.style.fontWeight = "bold"; - labelDiv.style.pointerEvents = "none"; - labelDiv.style.zIndex = "5"; - labelDiv.style.transform = "translate(-50%, -50%)"; - - (labelDiv as any).__updateLabelPos = () => { - if (!contourCheck.checked) { - labelDiv.style.display = "none"; - return; - } - const proj = labelPos.clone().project(camera); - const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; - const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; - - if (proj.z > 1) { - labelDiv.style.display = "none"; - } else { - labelDiv.style.display = "block"; - labelDiv.style.left = `${x}px`; - labelDiv.style.top = `${y}px`; - } - }; - - viewerArea.appendChild(labelDiv); - labelElements.push(labelDiv); - labelsDirty = true; - } - - // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. - [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { - if (points.length === 0) return; - const geometry = new THREE.BufferGeometry().setFromPoints(points); - const material = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: 0.8, - }); - contourGroup.add(new THREE.LineSegments(geometry, material)); - }); - - if (minH !== Infinity && maxH !== -Infinity) { - const nearestMin10 = Math.round(minH / 10) * 10; - const nearestMax10 = Math.round(maxH / 10) * 10; - maxValSpan.textContent = `${nearestMax10}m`; - minValSpan.textContent = `${nearestMin10}m`; - legendBar.style.display = "flex"; - } else { - legendBar.style.display = "none"; - } - return true; - } catch (e) { - legendBar.style.display = "none"; - return false; - } - } + ): Promise => + loadContourLinesInto( + { + viewerArea, + legendBar, + maxValSpan, + minValSpan, + intervalInput, + camera, + contourGroup, + contourCheck, + projectId: () => currentProjectId, + currentModelId: () => currentModelId, + currentModelSmooth: () => currentModelSmooth, + labelElements, + setLabelsDirty: (dirty) => { + labelsDirty = dirty; + }, + clearContours, + }, + modelId, + isSmooth, + recalculate, + ); async function loadSelectedContours( modelId: number, @@ -794,6 +539,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { scaleBar.hidden = false; + compass.setVisible(true); + compass.update( + camera.position.x - controls.target.x, + camera.position.y - controls.target.y, + camera.position.z - controls.target.z, + ); const dist = camera.position.distanceTo(controls.target); const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); const roughMeters = 100 * metersPerPixel; @@ -803,6 +554,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { scaleBar.hidden = true; + compass.setVisible(false); } // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts new file mode 100644 index 00000000..9df8648a --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts @@ -0,0 +1,209 @@ +/* ============================================================================= + * B04_PreProcess_UI_TerrainViewer_Chrome.ts + * 지표면 3D 뷰어의 **DOM 뼈대** — 상태줄·표시 토글·등고선 간격 폼·뷰포트·축척 막대· + * 방위 컴파스·표고 범례·로딩 서클을 만들어 넘긴다. + * + * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 만드는 순서·클래스·인라인 스타일은 옮기기 전 그대로다. 이벤트 배선과 THREE 렌더링은 + * 뷰어 본체에 남아 있고, 여기서는 엘리먼트만 만든다. + * ========================================================================== */ + +import { createTerrainCompass } from "@ui/ui_template_compass"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 뷰어 본체가 이어 쓸 엘리먼트 묶음 — 이름은 분리 전 지역변수와 같다. */ +export type TerrainViewerChrome = ReturnType; + +export function buildTerrainViewerChrome() { + const root = document.createElement("div"); + root.className = "terrain-model-group"; + + const statusSpan = document.createElement("span"); + statusSpan.className = "terrain-status"; + statusSpan.style.fontSize = "var(--text-caption)"; + statusSpan.style.color = "var(--color-text-secondary)"; + statusSpan.textContent = "모델 선택 대기 중..."; + + const axesCheck = document.createElement("input"); + axesCheck.type = "checkbox"; + axesCheck.checked = false; + + const rightControls = document.createElement("div"); + rightControls.className = "viewer-options model-display-options"; + + // Surface Toggle + const surfLabel = document.createElement("label"); + surfLabel.className = "toggle-label toggle-button"; + const surfCheck = document.createElement("input"); + surfCheck.type = "checkbox"; + surfCheck.checked = true; + surfLabel.append(surfCheck, document.createTextNode(" 서피스")); + + // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. + // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). + // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. + const smoothLabel = document.createElement("label"); + smoothLabel.className = "b04-surface__field"; + const smoothCaption = document.createElement("span"); + smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); + const smoothSelect = document.createElement("select"); + smoothSelect.className = "b04-surface__select"; + ( + [ + ["on", "B04_Surface_Smoothing_On"], + ["off", "B04_Surface_Smoothing_Off"], + ] as const + ).forEach(([value, key]) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = L(key); + smoothSelect.append(option); + }); + smoothSelect.value = "on"; + smoothLabel.append(smoothCaption, smoothSelect); + + // Contour Toggle + const contourLabel = document.createElement("label"); + contourLabel.className = "toggle-label toggle-button"; + const contourCheck = document.createElement("input"); + contourCheck.type = "checkbox"; + contourCheck.checked = true; + contourLabel.append(contourCheck, document.createTextNode(" 등고선")); + + // Contour Interval input form + const intervalForm = document.createElement("form"); + intervalForm.className = "contour-interval-form"; + + const intervalInput = document.createElement("input"); + intervalInput.type = "number"; + intervalInput.value = "1.0"; + intervalInput.step = "0.5"; + intervalInput.min = "0.5"; + intervalInput.className = "contour-interval-input"; + + const intervalSubmit = document.createElement("button"); + intervalSubmit.type = "submit"; + intervalSubmit.textContent = "적용"; + intervalSubmit.className = "contour-interval-submit"; + + intervalForm.append( + document.createTextNode("간격 "), + intervalInput, + document.createTextNode("m "), + intervalSubmit, + ); + + const axesLabel = document.createElement("label"); + axesLabel.className = "toggle-label toggle-button"; + axesLabel.append(axesCheck, document.createTextNode(" 축")); + rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); + + // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. + const optionsContent = document.createElement("div"); + optionsContent.className = "terrain-options-content"; + optionsContent.append(rightControls, statusSpan); + + // 3D View container + const viewerArea = document.createElement("div"); + viewerArea.className = "three-viewer"; + viewerArea.style.position = "relative"; + viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; + viewerArea.style.overflow = "hidden"; + + const canvas = document.createElement("canvas"); + canvas.className = "b04-surface-viewer__canvas"; + viewerArea.append(canvas); + root.append(viewerArea); + + // Scale bar overlay + const scaleBar = document.createElement("div"); + scaleBar.className = "b04-surface__scale"; + scaleBar.hidden = true; + + const scaleLabel = document.createElement("span"); + scaleBar.append(scaleLabel); + viewerArea.append(scaleBar); + + // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. + const compass = createTerrainCompass({ className: "b04-surface__compass" }); + viewerArea.append(compass.root); + + // Elevation bounds legend bar overlay (I-403) + const legendBar = document.createElement("div"); + legendBar.style.position = "absolute"; + legendBar.style.top = "16px"; + legendBar.style.right = "16px"; + legendBar.style.background = "rgba(255, 255, 255, 0.9)"; + legendBar.style.border = "1px solid #cbd5e1"; + legendBar.style.borderRadius = "6px"; + legendBar.style.padding = "8px"; + legendBar.style.width = "50px"; + legendBar.style.display = "none"; // hidden until contours are loaded + legendBar.style.flexDirection = "column"; + legendBar.style.alignItems = "center"; + legendBar.style.zIndex = "10"; + legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; + legendBar.style.pointerEvents = "none"; + + const maxValSpan = document.createElement("span"); + maxValSpan.style.fontSize = "10px"; + maxValSpan.style.fontWeight = "bold"; + maxValSpan.style.color = "#b91c1c"; + maxValSpan.style.marginBottom = "4px"; + + const gradientDiv = document.createElement("div"); + gradientDiv.style.width = "12px"; + gradientDiv.style.height = "120px"; + gradientDiv.style.background = + "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; + gradientDiv.style.borderRadius = "2px"; + gradientDiv.style.border = "1px solid #94a3b8"; + + const minValSpan = document.createElement("span"); + minValSpan.style.fontSize = "10px"; + minValSpan.style.fontWeight = "bold"; + minValSpan.style.color = "#1d4ed8"; + minValSpan.style.marginTop = "4px"; + + legendBar.append(maxValSpan, gradientDiv, minValSpan); + viewerArea.append(legendBar); + + // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + return { + root, + statusSpan, + axesCheck, + rightControls, + surfLabel, + surfCheck, + smoothLabel, + smoothCaption, + smoothSelect, + contourLabel, + contourCheck, + intervalForm, + intervalInput, + intervalSubmit, + axesLabel, + optionsContent, + viewerArea, + canvas, + scaleBar, + scaleLabel, + compass, + legendBar, + maxValSpan, + gradientDiv, + minValSpan, + progress, + }; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts new file mode 100644 index 00000000..a9e59ff2 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * B04_PreProcess_UI_TerrainViewer_Contours.ts + * 지표면 3D 뷰어의 **등고선 적재** — 서버(또는 보관함)에서 등고선을 받아 THREE 라인으로 + * 얹고, 표고 범례(최고·최저)를 갱신한다. + * + * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 본문 로직·수치는 그대로이고, 뷰어 클로저가 쥐고 있던 값만 `ctx` 로 받는다. + * ========================================================================== */ + +import * as THREE from "three"; + +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedJson } from "../A00_Common/b_asset_cache"; + +/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ +const MAX_CONTOUR_LABELS = 40; + +/** 뷰어 본체가 쥔 값 중 등고선 적재에 필요한 것들. */ +export interface ContourLoadContext { + viewerArea: HTMLElement; + legendBar: HTMLElement; + maxValSpan: HTMLElement; + minValSpan: HTMLElement; + intervalInput: HTMLInputElement; + camera: THREE.PerspectiveCamera; + contourGroup: THREE.Group; + contourCheck: HTMLInputElement; + /** 지금 보고 있는 프로젝트·모델 — 응답이 늦게 와도 최신 요청만 반영하려고 함수로 받는다. */ + projectId: () => string; + currentModelId: () => number | null; + currentModelSmooth: () => boolean; + /** 등고 라벨 DOM 목록(뷰어와 **같은 배열**을 공유한다)과 다시 배치하라는 표시. */ + labelElements: HTMLDivElement[]; + setLabelsDirty: (dirty: boolean) => void; + clearContours: () => void; +} + +export async function loadContourLinesInto( + ctx: ContourLoadContext, + modelId: number, + isSmooth: boolean, + recalculate = false, +): Promise { + const interval = parseFloat(ctx.intervalInput.value) || 1.0; + const projectId = ctx.projectId(); + const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; + + try { + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson(projectId, contourUrl); + if ( + ctx.projectId() !== projectId || + ctx.currentModelId() !== modelId || + ctx.currentModelSmooth() !== isSmooth || + (parseFloat(ctx.intervalInput.value) || 1.0) !== interval + ) { + return false; + } + + ctx.clearContours(); + + const bounds = data.bounds; + if (!bounds) return false; + + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + + const transform = (coords: [number, number, number][]) => { + return coords.map(([x_model, y_model, z_model]) => { + const x_scene = x_model - cx; + const y_scene = z_model - cz; + const z_scene = -(y_model - cy); + return new THREE.Vector3(x_scene, y_scene, z_scene); + }); + }; + + let minH = Infinity; + let maxH = -Infinity; + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. + // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; + const labelCandidates: { + level: number; + position: THREE.Vector3; + length: number; + }[] = []; + + data.contours.forEach((c: any) => { + if (c.level < minH) minH = c.level; + if (c.level > maxH) maxH = c.level; + + const points = transform(c.coordinates); + if (points.length < 2) return; + + const isMajor = c.level % (interval * 5) === 0; + const bucket = isMajor ? majorPoints : minorPoints; + for (let i = 0; i < points.length - 1; i++) { + bucket.push(points[i], points[i + 1]); + } + + // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 + // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 + // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. + if (isMajor && points.length > 4) { + let length = 0; + for (let i = 0; i < points.length - 1; i++) { + length += points[i].distanceTo(points[i + 1]); + } + labelCandidates.push({ + level: c.level, + position: points[Math.floor(points.length / 2)], + length, + }); + } + }); + + labelCandidates.sort((a, b) => b.length - a.length); + for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { + const labelPos = candidate.position; + const labelDiv = document.createElement("div"); + labelDiv.className = "contour-label"; + labelDiv.innerText = `${Math.round(candidate.level)}m`; + labelDiv.style.position = "absolute"; + labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; + labelDiv.style.border = "1px solid #d97706"; + labelDiv.style.color = "#b45309"; + labelDiv.style.padding = "1px 4px"; + labelDiv.style.borderRadius = "3px"; + labelDiv.style.fontSize = "9px"; + labelDiv.style.fontWeight = "bold"; + labelDiv.style.pointerEvents = "none"; + labelDiv.style.zIndex = "5"; + labelDiv.style.transform = "translate(-50%, -50%)"; + + (labelDiv as any).__updateLabelPos = () => { + if (!ctx.contourCheck.checked) { + labelDiv.style.display = "none"; + return; + } + const proj = labelPos.clone().project(ctx.camera); + const x = (proj.x * 0.5 + 0.5) * ctx.viewerArea.clientWidth; + const y = (-(proj.y * 0.5) + 0.5) * ctx.viewerArea.clientHeight; + + if (proj.z > 1) { + labelDiv.style.display = "none"; + } else { + labelDiv.style.display = "block"; + labelDiv.style.left = `${x}px`; + labelDiv.style.top = `${y}px`; + } + }; + + ctx.viewerArea.appendChild(labelDiv); + ctx.labelElements.push(labelDiv); + ctx.setLabelsDirty(true); + } + + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.8, + }); + ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); + }); + + if (minH !== Infinity && maxH !== -Infinity) { + const nearestMin10 = Math.round(minH / 10) * 10; + const nearestMax10 = Math.round(maxH / 10) * 10; + ctx.maxValSpan.textContent = `${nearestMax10}m`; + ctx.minValSpan.textContent = `${nearestMin10}m`; + ctx.legendBar.style.display = "flex"; + } else { + ctx.legendBar.style.display = "none"; + } + return true; + } catch (e) { + ctx.legendBar.style.display = "none"; + return false; + } +} diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 05cd24bd..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; @@ -275,7 +290,9 @@ export interface RouteResetResponse { } /** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면 - * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. */ + * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. + * 초기 설계가 실패로 끝난 프로젝트는 서버가 409로 거부한다 — 되돌릴 기준이 없어 + * 재계산으로 얼버무리지 않는다(2026-09-02). 그 안내 문구가 그대로 오류 토스트에 뜬다. */ export async function resetRouteDesign(projectId: string): Promise { return requestJson( `/projects/${projectId}/route/reset`, @@ -290,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..4a492dcb 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,11 +26,20 @@ export interface StructureOptionField { default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; + /** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */ + enabled?: boolean; /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ phase?: "b05" | "detail"; } +/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세 + * 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */ +export interface StructuresSectionOptions { + /** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */ + includeDetail?: boolean; +} + /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ export function isB05Option(option: StructureOptionField): boolean { return (option.phase ?? "b05") !== "detail"; @@ -45,6 +55,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 +141,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 +196,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 +242,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_Node.ts b/B05_Profile/B05_Profile_Corridor_Node.ts new file mode 100644 index 00000000..013eeb13 --- /dev/null +++ b/B05_Profile/B05_Profile_Corridor_Node.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * B05_Profile_Corridor_Node.ts + * 코리도(3D 예상형상)를 **서버에서 한 번** 만들어 저장본 형식으로 내놓는 진입점. + * + * 왜 있나(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤 + * 전처리 체인 마지막에 계산해 영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 → + * [저장]·[확정] 때 저장. 예전에는 사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어 + * 첫 진입이 느렸다. + * + * **계산을 다시 짜지 않는다.** 브라우저가 쓰는 빌더(`_UI_Corridor_Build`)와 저장 형식 + * (`_UI_Corridor_Envelope`)을 그대로 부른다 — 두 벌이 되면 화면과 서버가 다른 그림을 + * 만든다. 빌더 계통에는 Three.js·화면 API가 없어 Node에서 그대로 돈다. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세(API와 같은 꼴), route_points: 노선 폴리라인 } + * 출력 브라우저가 GET으로 받는 저장본 그대로(버전·해시 포함) + * 끝 코드: 0 성공 / 2 인자 오류 / 3 측점 부족(만들 것 없음) + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import { buildCorridor } from "./B05_Profile_UI_Corridor_Build"; +import { corridorHash, serialize } from "./B05_Profile_UI_Corridor_Envelope"; + +interface CorridorNodeInput { + detail: SectionDetailResponse; + route_points: RoutePoint[]; +} + +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 CorridorNodeInput; +const detail = input.detail; +const routePoints = input.route_points ?? []; +// 편집 전이므로 계획선 샘플은 정본 그대로다 — 브라우저의 폴백 경로와 같은 값이다. +const samples = detail.longitudinal?.design_profiles?.[0]?.samples; +const build = buildCorridor(detail.cross_sections, routePoints, samples); +if (!build) { + console.error("코리도 없음 — 분류 가능한 측점이 2개 미만."); + process.exit(3); +} +writeFileSync(outputPath, JSON.stringify(serialize(build, corridorHash(detail, routePoints)))); diff --git a/B05_Profile/B05_Profile_Corridor_Prebuild.py b/B05_Profile/B05_Profile_Corridor_Prebuild.py new file mode 100644 index 00000000..9361fac2 --- /dev/null +++ b/B05_Profile/B05_Profile_Corridor_Prebuild.py @@ -0,0 +1,86 @@ +"""코리도(3D 예상형상) 사전 생성 — 전처리 체인 마지막에 서버가 한 번 만들어 영구저장한다. + +왜(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤 계산해 +영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 → [저장]·[확정] 때 저장. 예전에는 +사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어(실측 17MB 규모) 첫 진입이 느렸다. + +**계산을 다시 짜지 않는다.** 브라우저가 쓰는 TS 빌더를 Node로 그대로 돌린다 +(`B05_Profile_Corridor_Node.ts` → 번들). 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 +화면과 3D가 갈라진다. + +실패는 비치명적이다 — 저장본이 없으면 브라우저가 예전처럼 직접 만든다(폴백 유지). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import tempfile +from pathlib import Path +from typing import Any +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" + + +async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None: + """브라우저가 받는 것과 **같은** 종횡단 상세를 만든다 — 같은 라우터 함수를 부른다.""" + from B06_Section.B06_Section_Router import get_section_detail + + response = await get_section_detail(UUID(str(project_id)), route_id) + payload = getattr(response, "model_dump", None) + if payload is None: # JSONResponse = 실패 + logger.warning("코리도 사전 생성: 종횡단 상세를 못 받음 (route_id=%s)", route_id) + return None + return payload(mode="json") + + +async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: Path) -> bool: + """코리도를 만들어 영구저장소에 남긴다. 성공하면 참.""" + detail = await _section_detail(project_id, route_id) + if detail is None: + return False + pool = get_db_pool() + async with pool.acquire() as connection: + points = await get_route_points(connection, route_id) + if len(points) < 2: + logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id) + return False + + if bundle_stale(BUNDLE) and not await asyncio.to_thread(build_bundle, "build:corridor"): + return False + + target = corridor_path(Path(project_root), route_id) + with tempfile.TemporaryDirectory(prefix="corridor_") as workdir: + source = Path(workdir) / "input.json" + built = Path(workdir) / "corridor.json" + await asyncio.to_thread( + source.write_text, + json.dumps({"detail": detail, "route_points": points}, default=float), + "utf-8", + ) + 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) + logger.info( + "코리도 사전 생성 완료: route_id=%s 크기=%.1fMB", + route_id, + target.stat().st_size / (1024 * 1024), + ) + return True + + +def _replace(source: Path, target: Path) -> None: + """임시 폴더가 다른 드라이브일 수 있어 os.replace 대신 복사 후 지운다.""" + target.write_bytes(source.read_bytes()) + source.unlink(missing_ok=True) 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 cd4e1b6f..e250d8f4 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -55,7 +55,9 @@ class GradeDesignOptions: min_tangent_length_m: float vertical_curve_skip_delta_pct: float design_speed_kph: int - terrain_type: str = "normal" + # 기본 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이다. + # 잘못된 값이 들어오면 아래 판정 함수들이 "normal" 로 되눌러 법정 상한을 낮게 잡는다. + terrain_type: str = "special" paved: bool = False main_direction: str = "auto" balance_segment_length_m: float | None = None @@ -137,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 b76f9be8..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, } @@ -187,6 +197,8 @@ def build_curves( pvi_z: np.ndarray, policy: AlignmentPolicy, curve_radii: dict[str, float] | None = None, + *, + only_explicit: bool = False, ) -> tuple[list[dict[str, Any]], list[str]]: """각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다. @@ -195,6 +207,10 @@ def build_curves( 곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해 좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다). + + `only_explicit=True` 면 **사용자가 R을 지정한 변화점에만** 곡선을 넣는다. 전체 측점 + 폴리라인(2026-09-02 사용자 확정)은 모든 측점이 변화점이라 기본 곡선을 다 넣으면 + 계획고가 지반고에서 떠 버린다 — 라운드는 [직선화]·틸팅으로 사용자가 만들 때만 생긴다. """ overrides = curve_radii or {} spans = pvi_s[1:] - pvi_s[:-1] @@ -209,6 +225,8 @@ def build_curves( key = chainage_key(chainage) if abs(delta) < 1e-9: continue + if only_explicit and overrides.get(key) is None: + continue # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. # 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을 # 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능). @@ -370,8 +388,13 @@ def build_alignment( stations: list[dict[str, Any]], policy: AlignmentPolicy, edits: dict[str, Any] | None = None, + only_explicit_curves: bool = False, ) -> dict[str, Any]: - """자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다.""" + """자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다. + + `only_explicit_curves=True` 는 전체 측점 폴리라인용 — 사용자가 R을 지정한 변화점에만 + 종단곡선을 넣는다(`build_curves(only_explicit=...)` 와 같은 뜻). + """ edits = edits or {} station_offsets = { str(key): float(value) @@ -385,7 +408,9 @@ def build_alignment( } pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) - curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii) + curves, warnings = build_curves( + pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves + ) segments = _segments(pvi_s, pvi_z) # 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면 # 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를 @@ -452,6 +477,9 @@ def build_alignment( return { "schema_version": ALIGNMENT_SCHEMA_VERSION, "policy": policy.as_dict(), + # 화면 사본(`B05_Profile_UI_Profile_Alignment.ts`)이 같은 규칙으로 다시 그리려면 + # 이 값이 저장본에 남아 있어야 한다(2026-09-02 전체 측점 폴리라인). + "only_explicit_curves": bool(only_explicit_curves), "base_pvi": [ {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} for s, z in zip(base_s, base_z) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 87cdae81..b5991a44 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -5,8 +5,12 @@ [[B05_Profile_Engine_Grade_Alignment]] 의 기하 파생을 묶어 `design_profiles` 배열에 넣을 계획선 한 벌을 만든다. -세 진입점이 있다. - - `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을 +네 진입점이 있다. + - `design_ground_following_profile()` : **1차(기본, 2026-09-02 사용자 확정)**. 모든 측점을 + 변화점으로 삼아 계획고를 **원지반고 그대로** 두는 폴리라인이다. 종단곡선은 사용자가 + [직선화]·틸팅으로 만들 때만 생긴다. 관 측점만 정착하던 옛 방식은 관 사이가 길면 골을 + 성토로, 마루를 절토로 메워 최대 성토 +9.36m(용화 실측)가 남았다. + - `design_pipe_anchored_profile()` : 관 정착 선형(옛 1차). 배수유역도가 산출한 배관 배치 측점을 변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 → 배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다. @@ -26,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, @@ -49,6 +54,8 @@ ALIGNMENT_PROFILE_ID = "design_grade_line" # 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02). PIPE_ANCHORED_BASIS = "pipe_anchored" ALIGNMENT_BASIS = "station_alignment" +# 전체 측점 폴리라인(2026-09-02 사용자 확정) — 계획고 = 지반고. +GROUND_POLYLINE_BASIS = "ground_polyline" def infer_station_interval(stations: list[dict[str, Any]]) -> float: @@ -138,6 +145,99 @@ def _clearance_at( return best +def design_ground_following_profile( + longitudinal: dict[str, Any], + options: GradeDesignOptions, + *, + station_interval_m: float | None = None, + edits: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선. + + 규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원): + - **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다. + - **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는 + 변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다. + - 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가 + 되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다 + (`design_pipe_anchored_profile` 과 같은 방식). + - 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다 + (기존 정책과 같다). + + 시·종점 오프셋(`start/end_elevation_offset_m`)은 그대로 반영한다. 기본값 0이라 + 평소에는 양끝도 지반고다. + """ + options.validate() + chainage, ground = ground_profile(longitudinal) + total = float(chainage[-1]) + if total <= 0: + raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") + + stations = list(longitudinal.get("stations") or []) + interval = float(station_interval_m or 0) or infer_station_interval(stations) + policy = AlignmentPolicy.from_config( + station_interval_m=interval, + 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) + # 측점 목록이 곧 변화점 목록이다. 측점이 비었거나 양끝이 빠져 있으면 종단 격자를 쓴다. + node_s = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] + node_s = sorted({round(value, 3) for value in node_s if 0.0 <= value <= total}) + if len(node_s) < 2: + node_s = [round(float(value), 3) for value in chainage] + if node_s[0] > 0.0: + node_s.insert(0, 0.0) + if node_s[-1] < total: + node_s.append(round(total, 3)) + + base_s = np.array(node_s, dtype=np.float64) + base_z = np.interp(base_s, chainage, ground) + base_z[0] += options.start_elevation_offset_m + base_z[-1] += options.end_elevation_offset_m + + rise = float(base_z[-1] - base_z[0]) + direction, note = ( + detect_main_direction(ground, rise) + if options.main_direction == "auto" + else (options.main_direction, None) + ) + if note: + warnings.append(note) + + # 대칭 종단곡선은 꼭짓점을 지나지 않는다(중앙종거 |A|·L/8) — 보정 없이 곡선을 넣으면 + # 계획고가 지반고에서 뜬다. 호가 측점 세로선과 만나는 점이 지반고가 되도록 꼭짓점을 + # 밀어내며, 표고가 움직이면 대수차도 변하므로 수렴할 때까지 되풀이한다. + target = base_z.copy() + for _ in range(12): + curves, _curve_warnings = build_curves(base_s, base_z, policy) + error = target - evaluate(base_s, base_z, curves, base_s) + error[0] = 0.0 + error[-1] = 0.0 + if float(np.max(np.abs(error))) < 1e-4: + break + base_z = base_z + error + + alignment = build_alignment( + base_s=base_s, + base_z=base_z, + chainage=chainage, + ground=ground, + stations=stations, + policy=policy, + edits=dict(edits or {}), + ) + warnings.extend(alignment["warnings"]) + balanced = bool(alignment["balance"]["within_tolerance"]) + return alignment, _profile_entry( + alignment, options, direction, balanced, warnings, GROUND_POLYLINE_BASIS + ) + + def design_pipe_anchored_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, @@ -173,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) @@ -288,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) @@ -365,6 +469,7 @@ def rebuild_alignment_profile( base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64) policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) + previous = (longitudinal.get("design_profiles") or [{}])[0] alignment = build_alignment( base_s=base_s, base_z=base_z, @@ -374,7 +479,6 @@ def rebuild_alignment_profile( policy=policy, edits=edits, ) - previous = (longitudinal.get("design_profiles") or [{}])[0] criteria = previous.get("criteria") or {} options = GradeDesignOptions( max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 9596f81b..491a76fc 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -15,6 +15,7 @@ from typing import Any from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, design_grade_line from B05_Profile.B05_Profile_Engine_Grade_Profile import ( design_alignment_profile, + design_ground_following_profile, design_pipe_anchored_profile, ) from B05_Profile.B05_Profile_Engine_Sections_Core import ( @@ -99,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, @@ -112,7 +115,7 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P 물린 자리와 측점 자리가 어긋난다. 관 지점 파일에는 저장 당시 노선 지문이 함께 있다. 지문이 달라도 저장분에 좌표가 있으면 - **이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 CSV)과 여기 계획선은 + **이 계획선에 투영해 이월한다** — B04가 관 자리를 정한 선(계획노선 정본)과 여기 계획선은 같은 자리를 지나면서 연장이 다르다(실측 350.11m vs 354.83m). 그래서 지문은 거의 항상 달랐고 관이 통째로 빠졌다(2026-08-30 사용자 지적). 좌표가 없는 구 저장분만 버린다. """ @@ -214,18 +217,24 @@ def _append_design_profiles( ) -> dict[str, Any] | None: """종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다. - 1순위는 **배관 정착 선형** — 배수유역도의 배관 배치 측점마다 계획선이 지면선과 - 만나도록 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관이 없거나 - 산출이 불가하면 2순위로 기존 지반 추종 직선 분할 선형(`design_alignment_profile`), - 그마저 실패하면 구 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 - 종횡단 생성 자체는 유지한다. + 1순위는 **전체 측점 폴리라인**(`design_ground_following_profile`) — 모든 측점의 + 계획고를 원지반고에 맞추고 종단곡선은 넣지 않는다(2026-09-02 사용자 확정). + 실패하면 2순위 배관 정착 선형(`design_pipe_anchored_profile`, 옛 1차), + 3순위 지반 추종 직선 분할 선형(`design_alignment_profile`), 그마저 실패하면 구 + 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 종횡단 생성 자체는 유지한다. """ longitudinal.setdefault("design_profiles", []) if grade_options is None: return None alignment = None profile = None - if pipe_chainages: + try: + alignment, profile = design_ground_following_profile( + longitudinal, grade_options, station_interval_m=station_interval_m + ) + except (ValueError, KeyError, ArithmeticError): + logger.exception("B05 전체 측점 폴리라인 산출 실패 — 배관 정착 선형으로 대체") + if profile is None and pipe_chainages: try: alignment, profile = design_pipe_anchored_profile( longitudinal, 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 cb4214b7..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 @@ -28,7 +29,8 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"]) MAX_CORRIDOR_BYTES = 64 * 1024 * 1024 -def _corridor_path(project_root: Path, route_id: int) -> Path: +def corridor_path(project_root: Path, route_id: int) -> Path: + """코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다.""" return project_root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json" @@ -63,21 +65,58 @@ 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: return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."} ) - path = _corridor_path(project_root, route_id) + path = corridor_path(project_root, route_id) if not path.is_file(): return JSONResponse( 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: @@ -109,7 +148,7 @@ async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(... return JSONResponse( status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."} ) - path = _corridor_path(project_root, route_id) + path = corridor_path(project_root, route_id) await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(atomic_write_json, path, payload) return JSONResponse(content={"status": "ok"}) diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 1e087863..8450ff4e 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -149,20 +149,26 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: `edits/pipe_points.json`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시 파생되기 때문이다. - 스냅샷이 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든 - 사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage 2·3은 - IN_PROGRESS(검토 대기)가 된다. + 초기 설계 체인이 **실패로 끝난 프로젝트**(`initial_design.failed` 마커)는 되돌릴 + 기준이 없다 — 재계산으로 얼버무리지 않고 409로 관리자 문의를 안내한다. 부분 결과는 + 분석 안 됨과 다르지 않다(2026-09-02 사용자 확정). + + 마커도 스냅샷도 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 + 경로든 사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage + 2·3은 IN_PROGRESS(검토 대기)가 된다. """ from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files from common_util.common_util_initial_snapshot import ( has_initial_snapshot, + is_design_failed, + read_design_failure, restore_initial_snapshot, restore_snapshot_files, wipe_edited_masters, ) - from common_util.common_util_surface_confirmation import surface_confirmation_defaults + from common_util.common_util_surface_confirmation import get_surface_confirmation_params pool = get_db_pool() try: @@ -170,12 +176,39 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) if stored_path else None restored = bool(project_root and has_initial_snapshot(project_root)) + if not restored and project_root and is_design_failed(project_root): + # 초기 설계가 깨진 프로젝트 — 되돌릴 기준이 없다. 재계산은 사용자가 본 초기 + # 화면과 다른 값을 낳으므로 하지 않는다(2026-09-02 사용자 확정). + reason = read_design_failure(project_root) or "초기 설계 처리 실패" + logger.warning( + "B05 초기화 거부(초기 설계 실패 프로젝트): project_id=%s reason=%s", + project_id, + reason, + ) + return JSONResponse( + status_code=409, + content={ + "status": "error", + "code": "initial_design_failed", + "reason": reason, + "message": ( + "초기 설계가 완료되지 않아 되돌릴 초기값이 없습니다. " + "관리자에게 문의해 주세요." + ), + }, + ) + # 복원이 만든 새 route id — 코리도 초기값을 그 번호로 되돌리는 데 쓴다. + restored_route_id: int | None = None async with pool.acquire() as connection: - # 확정 지표면 모델을 초기 체인과 같은 기준(config 기본값)으로 다시 찾는다. + # 확정 지표면 모델을 초기 체인과 **같은 기준**으로 다시 찾는다 — 체인은 stage 1 + # 확정 선택값을 쓴다(2026-08-30). config 기본값을 쓰면 그 선택과 어긋난 모델을 + # 집어 재계산 결과가 초기값과 달라진다(2026-09-02 실측: `classification`/5m + # 확정 프로젝트에 `csf`/1m 기본값이 걸림). try: + selection = await get_surface_confirmation_params(connection, str(project_id)) surface_model_id: int | None = await find_surface_model_for_selection( - connection, project_id, surface_confirmation_defaults() + connection, project_id, selection ) except Exception: surface_model_id = None @@ -188,7 +221,9 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: deleted = cursor.rowcount # 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다. if restored and project_root: - await restore_initial_snapshot(connection, project_root, str(project_id)) + restored_route_id = await restore_initial_snapshot( + connection, project_root, str(project_id) + ) await connection.commit() except Exception: await connection.rollback() @@ -196,8 +231,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: if restored and project_root: # 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아 - # 초기값이 오염된다(2026-08-29). - await asyncio.to_thread(restore_snapshot_files, project_root) + # 초기값이 오염된다(2026-08-29). 코리도는 새 route id 이름으로 되돌아간다. + await asyncio.to_thread(restore_snapshot_files, project_root, restored_route_id) else: # 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야 # 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다 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 2c89dc1a..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 @@ -91,7 +94,8 @@ class RouteSolveRequest(BaseModel): long_sample_interval_m: float | None = Field(default=None, gt=0) # 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다. - terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)") + # 기본 특수지형(2026-09-02 사용자 지시) — B05 좌측 패널 기본 선택과 같은 값이다. + terrain_type: str = Field(default="special", description="지형 구분 (normal/special)") # 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이 # 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정). # 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다. @@ -115,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..1e898cfb 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", @@ -504,10 +505,65 @@ "placement": "interval", "style": { "color": "#2ec5dc", - "abbr": "소단" + "abbr": "소단구" }, "drawing_views": ["cross_section", "quantity"], - "options": [] + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] }, { "type_id": "chute", @@ -639,6 +695,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -697,6 +760,22 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -755,6 +834,22 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -813,6 +908,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -871,6 +973,89 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": ["메쌓기", "찰쌓기"], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": ["profile", "cross_section", "quantity"], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" } ] }, 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 0541eadc..7ce3ea85 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -14,14 +14,21 @@ * ========================================================================== */ 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"; import { - buildCorridor, - type CorridorBuildResult, - type CorridorRibbon, -} from "./B05_Profile_UI_Corridor_Build"; -import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures"; + corridorHash, + ENVELOPE_VERSION, + deserialize, + serialize, + type CorridorEnvelope, +} from "./B05_Profile_UI_Corridor_Envelope"; /** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */ export interface CorridorStructureHook { @@ -29,58 +36,6 @@ export interface CorridorStructureHook { applies: (chainageStart: number, chainageEnd: number) => boolean; } -/** - * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. - * - * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. - * - * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. - * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. - * 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자). - * 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가 - * 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02). - */ -const ENVELOPE_VERSION = 6; - -interface CorridorEnvelope { - version: number; - hash: string; - ribbons: Array<{ - kind: CorridorRibbon["kind"]; - side: CorridorRibbon["side"]; - colCount: number; - chainages: number[]; - positionsBase64: string; - /** - * 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도 - * 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연 - * 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다. - */ - patch?: boolean; - patchClip?: boolean; - /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ - cellMaskBase64?: string; - /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ - trimTrisBase64?: string; - trimUvsBase64?: string; - }>; - outline: CorridorBuildResult["outline"]; - /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ - caps?: CorridorBuildResult["caps"]; - /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ - structures?: CorridorBuildResult["structures"]; - /** - * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 - * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. - */ - planCurves?: CorridorBuildResult["planCurves"]; - /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ - cutWalls?: Array<{ - side: "left" | "right"; - positionsBase64: string; - }>; -} - interface CacheEntry { hash: string; build: CorridorBuildResult; @@ -93,262 +48,6 @@ function keyOf(projectId: string, routeId: number): string { return `${projectId}:${routeId}`; } -/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */ -function fnv1a(text: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < text.length; i += 1) { - hash ^= text.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -/** - * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** - * - * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 - * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 - * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 - * 섞어 두면 배포와 동시에 저장본이 만료된다. - */ -// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· -// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). -// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 -// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 -// 아니라 리본이 끝난 자리다(2026-08-27 사용자). -// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) + -// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정), -// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토) -// (2026-08-27 사용자). -// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다 -// (2026-08-27 사용자). -// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 -// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). -// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) -// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 -// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 -// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 -// 성토면이 있어야 한다 — 삭제가 아니다). -// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 -// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 -// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). -// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 -// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), -// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, -// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. -// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 -// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). -// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 -// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 -// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: -// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). -// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, -// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 -// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 -// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). -// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 -// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) -// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 -// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). -// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에 -// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.) -// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). -// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 -// (2026-08-27 사용자). 안쪽은 안 자른다. -// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 -// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 -// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 -// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). -// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, -// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 -// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 -// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). -// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 -// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — -// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). -// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). -// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열 -// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는 -// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다** -// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선 -// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다. -// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥 -// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이 -// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해 -// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히 -// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이 -// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다"). -// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로 -// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지 -// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m). -// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취 -// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다 -// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출"). -// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용). -// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로 -// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선 -// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고 -// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음 -// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이 -// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가 -// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은 -// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다. -// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로 -// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 -// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다 -// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로 -// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다 -// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까"). -// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어 -// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**. -// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다 -// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. -// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 -// 가르고 **닫힌 고리만** 담는다. -// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 -// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). -// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 -// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). -// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2, -// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계). -// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와 -// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자). -// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만 -// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치 -// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취 -// 영역을 내므로 같은 반대 규칙이 성립한다. -// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가 -// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 -// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — -// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. -const BUILD_VERSION = 90; - -/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ -export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { - const parts: Array = [`v${BUILD_VERSION}`, routePoints.length]; - routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2))); - detail.cross_sections.forEach((section) => { - const design = section.design; - parts.push( - section.chainage_m.toFixed(3), - section.center_x.toFixed(2), - section.center_y.toFixed(2), - ); - if (!design) { - parts.push("nodesign"); - return; - } - parts.push( - design.design_elevation_m.toFixed(3), - design.section_mode, - design.ditch_side ?? "", - design.ditch_type ?? "", - String(design.ditch_enabled ?? ""), - design.design_line.length, - ); - design.design_line.forEach((point) => - parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)), - ); - // 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다. - structureHashParts(section).forEach((part) => parts.push(part)); - }); - return fnv1a(parts.join("|")); -} - -function base64FromFloats(values: Float64Array): string { - const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function base64FromBytes(bytes: Uint8Array): string { - let binary = ""; - const CHUNK = 0x8000; - for (let i = 0; i < bytes.length; i += CHUNK) { - binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); - } - return btoa(binary); -} - -function bytesFromBase64(encoded: string): Uint8Array { - const binary = atob(encoded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -function floatsFromBase64(encoded: string): Float64Array { - const binary = atob(encoded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); - return new Float64Array(bytes.buffer); -} - -function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { - return { - version: ENVELOPE_VERSION, - hash, - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positionsBase64: base64FromFloats(ribbon.positions), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), - ...(ribbon.trimTris && ribbon.trimUvs - ? { - trimTrisBase64: base64FromFloats(ribbon.trimTris), - trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), - } - : {}), - })), - outline: build.outline, - caps: build.caps, - structures: build.structures, - planCurves: build.planCurves, - cutWalls: (build.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positionsBase64: base64FromFloats(wall.positions), - })), - }; -} - -function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { - return { - ribbons: envelope.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positions: floatsFromBase64(ribbon.positionsBase64), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), - ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 - ? { - trimTris: floatsFromBase64(ribbon.trimTrisBase64), - trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), - } - : {}), - })), - outline: envelope.outline, - caps: envelope.caps ?? [], - structures: envelope.structures ?? [], - planCurves: envelope.planCurves ?? [], - cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positions: floatsFromBase64(wall.positionsBase64), - })), - }; -} - async function requestCorridor(path: string, init: RequestInit): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); @@ -364,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" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. } } @@ -407,28 +143,51 @@ export type ProfileSamples = Array<{ ground_elevation_m?: number; }>; +/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나. + * 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다 + * (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */ +declare global { + interface Window { + __corridorSource?: { source: "stored" | "built"; hash: string; at: number }; + } +} + +function markSource(source: "stored" | "built", hash: string): void { + window.__corridorSource = { source, hash, at: Date.now() }; +} + export async function ensureCorridor( projectId: string, routeId: number, 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; } catch { // 손상 저장본 — 빌드로 폴백. } } + // 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아 + // 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫). + if (!rebuild) return null; + // 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이 + // 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다. + const hasStored = lookup.kind !== "missing"; // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 @@ -442,7 +201,8 @@ export async function ensureCorridor( cache.delete(key); return null; } - if (stored === null) { + markSource("built", hash); + if (!hasStored) { // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). cache.set(key, { hash, build, dirty: false }); void putStored(projectId, routeId, serialize(build, hash)).then((ok) => { @@ -464,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 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). @@ -477,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_Corridor_Envelope.ts b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts new file mode 100644 index 00000000..feb3aef9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Envelope.ts @@ -0,0 +1,329 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Envelope.ts + * 코리도 저장본 형식 — 버전·해시·직렬화 한 벌. **브라우저와 서버가 같은 것을 쓴다.** + * + * 서버(전처리 체인 끝)가 같은 빌더로 코리도를 미리 만들어 저장하므로(2026-09-04 사용자 + * 확정), 저장 형식과 버전 해시가 두 벌이 되면 브라우저가 서버 저장본을 남으로 보고 + * 통째로 다시 만든다. 그래서 이 파일 하나만 둔다 — 화면 API 를 쓰지 않아 Node 에서도 + * 그대로 돈다(`btoa`·`atob` 는 Node 16+ 전역). + * + * 원래 `_UI_Corridor.ts` 안에 있던 것을 그대로 옮겼다(2026-09-04 분리, 내용 불변). + * ========================================================================== */ + +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { CorridorBuildResult, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build_Types"; +import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures"; + +/** + * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. + * + * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. + * + * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. + * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. + * 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자). + * 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가 + * 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02). + */ +export const ENVELOPE_VERSION = 6; + +export interface CorridorEnvelope { + version: number; + hash: string; + ribbons: Array<{ + kind: CorridorRibbon["kind"]; + side: CorridorRibbon["side"]; + colCount: number; + chainages: number[]; + positionsBase64: string; + /** + * 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도 + * 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연 + * 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다. + */ + patch?: boolean; + patchClip?: boolean; + /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ + cellMaskBase64?: string; + /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ + trimTrisBase64?: string; + trimUvsBase64?: string; + }>; + outline: CorridorBuildResult["outline"]; + /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ + caps?: CorridorBuildResult["caps"]; + /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ + structures?: CorridorBuildResult["structures"]; + /** + * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 + * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. + */ + planCurves?: CorridorBuildResult["planCurves"]; + /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ + cutWalls?: Array<{ + side: "left" | "right"; + positionsBase64: string; + }>; +} + +/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */ +function fnv1a(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** + * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** + * + * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 + * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 + * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 + * 섞어 두면 배포와 동시에 저장본이 만료된다. + */ +// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· +// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). +// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 +// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. +// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 +// 아니라 리본이 끝난 자리다(2026-08-27 사용자). +// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) + +// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정), +// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토) +// (2026-08-27 사용자). +// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다 +// (2026-08-27 사용자). +// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 +// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). +// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) +// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 +// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 +// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 +// 성토면이 있어야 한다 — 삭제가 아니다). +// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 +// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 +// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). +// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 +// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), +// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, +// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. +// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 +// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). +// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 +// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 +// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: +// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). +// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, +// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 +// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 +// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). +// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 +// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) +// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 +// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). +// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에 +// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.) +// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). +// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 +// (2026-08-27 사용자). 안쪽은 안 자른다. +// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 +// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 +// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 +// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). +// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, +// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 +// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 +// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). +// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 +// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — +// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). +// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). +// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열 +// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는 +// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다** +// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선 +// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다. +// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥 +// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이 +// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해 +// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히 +// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이 +// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다"). +// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로 +// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지 +// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m). +// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취 +// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다 +// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출"). +// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용). +// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로 +// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선 +// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고 +// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음 +// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이 +// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가 +// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은 +// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다. +// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로 +// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 +// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다 +// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로 +// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다 +// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까"). +// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어 +// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**. +// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다 +// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. +// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 +// 가르고 **닫힌 고리만** 담는다. +// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 +// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). +// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 +// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). +// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2, +// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계). +// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와 +// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자). +// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만 +// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치 +// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취 +// 영역을 내므로 같은 반대 규칙이 성립한다. +// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가 +// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 +// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — +// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. +// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를 +// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길 +// 부재를 못 정하므로 만료한다. +// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로 +// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. +const BUILD_VERSION = 92; + +/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ +export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { + const parts: Array = [`v${BUILD_VERSION}`, routePoints.length]; + routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2))); + detail.cross_sections.forEach((section) => { + const design = section.design; + parts.push( + section.chainage_m.toFixed(3), + section.center_x.toFixed(2), + section.center_y.toFixed(2), + ); + if (!design) { + parts.push("nodesign"); + return; + } + parts.push( + design.design_elevation_m.toFixed(3), + design.section_mode, + design.ditch_side ?? "", + design.ditch_type ?? "", + String(design.ditch_enabled ?? ""), + design.design_line.length, + ); + design.design_line.forEach((point) => + parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)), + ); + // 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다. + structureHashParts(section).forEach((part) => parts.push(part)); + }); + return fnv1a(parts.join("|")); +} + +function base64FromFloats(values: Float64Array): string { + const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); + let binary = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +function base64FromBytes(bytes: Uint8Array): string { + let binary = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +function bytesFromBase64(encoded: string): Uint8Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function floatsFromBase64(encoded: string): Float64Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return new Float64Array(bytes.buffer); +} + +export function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { + return { + version: ENVELOPE_VERSION, + hash, + ribbons: build.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + colCount: ribbon.colCount, + chainages: ribbon.chainages, + positionsBase64: base64FromFloats(ribbon.positions), + ...(ribbon.patch ? { patch: true } : {}), + ...(ribbon.patchClip ? { patchClip: true } : {}), + ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), + ...(ribbon.trimTris && ribbon.trimUvs + ? { + trimTrisBase64: base64FromFloats(ribbon.trimTris), + trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), + } + : {}), + })), + outline: build.outline, + caps: build.caps, + structures: build.structures, + planCurves: build.planCurves, + cutWalls: (build.cutWalls ?? []).map((wall) => ({ + side: wall.side, + positionsBase64: base64FromFloats(wall.positions), + })), + }; +} + +export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { + return { + ribbons: envelope.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + colCount: ribbon.colCount, + chainages: ribbon.chainages, + positions: floatsFromBase64(ribbon.positionsBase64), + ...(ribbon.patch ? { patch: true } : {}), + ...(ribbon.patchClip ? { patchClip: true } : {}), + ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), + ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 + ? { + trimTris: floatsFromBase64(ribbon.trimTrisBase64), + trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), + } + : {}), + })), + outline: envelope.outline, + caps: envelope.caps ?? [], + structures: envelope.structures ?? [], + planCurves: envelope.planCurves ?? [], + cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ + side: wall.side, + positions: floatsFromBase64(wall.positionsBase64), + })), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index a6e39ba3..855fd589 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -496,10 +496,16 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou // 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단 계산 그대로의 솔리드 // (2026-08-23 사용자). 리본 격자와 무관한 독립 물체라 개별 Mesh로 얹는다. (build.structures ?? []).forEach((structure, index) => { + // 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다 + // (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다. + const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key }; if (structure.kind === "pipe") { const mesh = structurePipeMesh(structure, origin); if (mesh) { mesh.name = `corridor-structure:pipe:${index}`; + mesh.traverse((object) => { + object.userData = tag; + }); group.add(mesh); } return; @@ -517,6 +523,7 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou }), ); mesh.name = `corridor-structure:${structure.kind}:${index}`; + mesh.userData = tag; group.add(mesh); // 모서리 검은선 — 성토부·노폭 리본과 같은 규칙으로 경계를 또렷하게 // (2026-08-23 사용자). 임계각 위 모서리만 뽑아 스윕 분할선은 남기지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index c96fb326..e0c6913f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -176,12 +176,18 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { points.push({ offset_m: side.top.offset, elevation_m: side.top.elevation }); } points.push({ offset_m: wall.points[2].offset, elevation_m: wall.points[2].elevation }); - // 패치 리본(잘린 자리 다시 그리기)은 **성토부선만** 담는다 — 벽 전면·유로를 - // 리본으로 덮으면 관 구멍과 L자 통로가 막힌다(2026-08-25 사용자 ②). 벽·바닥은 - // 구조물 솔리드가 그린다. + // 패치 리본(잘린 자리 다시 그리기)은 **성토부선과 접근선까지** 담는다 — 벽 전면·유로는 + // 여전히 담지 않는다(리본으로 덮으면 관 구멍과 L자 통로가 막힌다, 2026-08-25 사용자 ②). + // 접근선(노견 → 벽 상단)은 벽보다 위라 덮어도 구멍을 막지 않는데, 이 구간을 비워 두니 + // 좁은 좌측 패치 사이로 원지반이 드러나 보였다(2026-09-03 사용자 확정: 「패치로 덮기」). const patchPoints: OffsetPoint[] = []; // 성토 구간은 **구간별로도** 담는다 — 패치 리본이 한 장씩 낸다(2026-08-27 사용자). const fillRuns: OffsetPoint[][] = []; + const approachRun = points.slice(); + if (approachRun.length >= 2) { + patchPoints.push(...approachRun); + fillRuns.push(approachRun); + } // 유출측 다단과 달리 세월교는 단 나눔이 없다 — 접지 후 절토선(kind="cut")은 뺀다. const fill = side.fillSegments.filter((segment) => segment.kind !== "cut"); let kind: "cut" | "fill" = "fill"; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 230aef90..935745ce 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -73,6 +73,12 @@ export interface RouteFrame { export interface CorridorStructure { chainage_m: number; kind: "revet" | "basin" | "pipe"; + /** + * 부재키 — B06이 쓰는 이름 그대로(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`· + * `basin`·`pipe`). 3D에서 부재 하나를 고르고 그 선택을 B06 카드·조정창으로 넘기는 + * 신원표다(2026-09-04). 세월교·BOX암거 부재는 아직 비워 둔다. + */ + key?: string; /** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */ polygon?: Array<[number, number]>; /** @@ -303,6 +309,8 @@ export function buildCorridorStructures( afterM: number, /** 벽 경로선 — 연동을 푼 측점이 있으면 링마다 단면을 갈아 끼운다(2026-08-30). */ path?: WallPath | null, + /** 부재키(2026-09-04 3D 개별 선택) — 없으면 신원표 없는 부재로 남는다. */ + key?: string, ): void => { if (points.length < 3) return; const chainages = ringChainagesOf(beforeM, afterM); @@ -311,6 +319,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: chainage, kind, + key, polygon: points.map((p) => [p.offset, p.elevation]), rings, }); @@ -319,6 +328,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: chainage, kind, + key, polygons: chainages.map((at) => polygonAt(path, at)), rings, }); @@ -327,7 +337,14 @@ export function buildCorridorStructures( if (revetLayout) { // 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다. for (const tier of revetLayout.tiers) { - pushSwept("revet", tier.polygon, revetLayout.span.beforeM, revetLayout.span.afterM); + pushSwept( + "revet", + tier.polygon, + revetLayout.span.beforeM, + revetLayout.span.afterM, + null, + "own", + ); } } @@ -365,6 +382,8 @@ export function buildCorridorStructures( boreChainages: number[] = [chainage], /** 벽 경로선(2026-08-30) — 링마다 그 자리 단면을 잘라 구멍을 낸다. */ path?: WallPath | null, + /** 부재키(2026-09-04 3D 개별 선택). */ + key?: string, ): void => { if (points.length < 3) return; const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]); @@ -372,7 +391,7 @@ export function buildCorridorStructures( const right = Math.min(...polygon.map(([offset]) => offset)); // 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다. if (left < bore.minOffset || right > bore.maxOffset) { - pushSwept(kind, points, beforeM, afterM, path); + pushSwept(kind, points, beforeM, afterM, path, key); return; } // 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다. @@ -417,7 +436,7 @@ export function buildCorridorStructures( for (const pieces of [upper, lower]) { // 어느 링에서든 비면 그 조각은 통째로 버린다 — 링 토폴로지를 맞춰야 한다. if (pieces.some((piece) => piece.length < 3)) continue; - solids.push({ chainage_m: chainage, kind, polygons: pieces, rings }); + solids.push({ chainage_m: chainage, kind, key, polygons: pieces, rings }); } }; @@ -442,10 +461,19 @@ export function buildCorridorStructures( ); for (const side of fordLayout.sides) { for (const part of side.parts) { - pushPierced("basin", part.points, half, half, fordBore, pipeChainages); + pushPierced( + "basin", + part.points, + half, + half, + fordBore, + pipeChainages, + null, + `${side.role}:${part.kind}`, + ); } } - pushSwept("basin", fordLayout.slabBridge, half, half); + pushSwept("basin", fordLayout.slabBridge, half, half, null, "body"); // 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면** // (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은 // 틀린 자리: 거긴 날개벽 투영이 만든 에이프런 끝이다). 발치는 바닥판 밑면, @@ -475,6 +503,8 @@ export function buildCorridorStructures( (point) => [point.offset, point.elevation] as [number, number], ), }, + key: `${side.role}:wing`, + apronKey: `${side.role}:apron`, }, ]; }), @@ -492,6 +522,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: at, kind: "pipe", + key: "pipe", pipe: { start: [invert[3].offset, invert[3].elevation], end: [invert[2].offset, invert[2].elevation], @@ -537,19 +568,42 @@ export function buildCorridorStructures( const span = wall.role === "inlet" ? inletSpan : outletSpan; const path = pathOf(wall.role, wall.points); // 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다. - if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path); + if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path, wall.role); else - pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore, [chainage], path); + pushPierced( + "revet", + wall.points, + span.beforeM, + span.afterM, + culvertBore, + [chainage], + path, + wall.role, + ); } // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). // 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`). layout.extraWalls.forEach((wall, i) => { const span = tierSpanOf(section, `extra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`extra${i}`, wall.points)); + pushSwept( + "revet", + wall.points, + span.beforeM, + span.afterM, + pathOf(`extra${i}`, wall.points), + `extra${i}`, + ); }); layout.basinExtras.forEach((wall, i) => { const span = tierSpanOf(section, `bextra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`bextra${i}`, wall.points)); + pushSwept( + "revet", + wall.points, + span.beforeM, + span.afterM, + pathOf(`bextra${i}`, wall.points), + `bextra${i}`, + ); }); if (layout.basin) { @@ -579,6 +633,9 @@ export function buildCorridorStructures( basinSpan.beforeM, basinSpan.afterM, culvertBore, + [chainage], + null, + "basin", ); } } @@ -588,6 +645,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: chainage, kind: "pipe", + key: "pipe", pipe: { start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation], end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation], diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts index ffeea745..182f5c68 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -181,6 +181,7 @@ export function boxSolids( { chainage_m: chainage, kind: "basin", + key: "body", polygons: repeat(layout.topSlab, bodyRings.length), rings: bodyRings, // 아령 외곽선의 **손잡이** — 에이프런과 합쳐 탑뷰 경계를 이룬다(2026-08-27 사용자). @@ -189,6 +190,7 @@ export function boxSolids( { chainage_m: chainage, kind: "basin", + key: "body", polygons: repeat(layout.bottomSlab, bodyRings.length), rings: bodyRings, }, @@ -204,6 +206,7 @@ export function boxSolids( solids.push({ chainage_m: chainage, kind: "basin", + key: "body", polygons: repeat(layout.barrel, wallRings.length), rings: wallRings, }); @@ -235,6 +238,7 @@ export function boxSolids( solids.push({ chainage_m: chainage, kind: "basin", + key: `${side.role}:apron`, polygons: apronPlan.map((at) => { const inner = innerAt(at); return [ @@ -281,6 +285,7 @@ function wingSolids( topElevation: side.topElevation, bottomElevation: side.bottomElevation, wing: wingOf(section, side.role) ?? box.wing_out, + key: `${side.role}:wing`, })), ); } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts index 10777549..399ea3f6 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts @@ -30,6 +30,10 @@ export interface WingAnchor { bottomElevation: number; /** 그 측에 적용할 날개벽 제원. */ wing: FordWingSpec; + /** 날개벽 부재키 — `그측이름:wing`(예 `inlet:wing`·`left:wing`). */ + key?: string; + /** 에이프런 부재키 — `그측이름:apron`. 날개벽과 따로 밝아져야 해 나눠 둔다. */ + apronKey?: string; /** * 세월교 전용 — 바닥 에이프런(2026-08-26 사용자 ④). 있으면 **날개 끝점과 바닥판 * 종방향 끝점을 이은 직선**을 외곽으로 하는 평면 삼각형을 세운다. @@ -170,7 +174,15 @@ export function buildWingSolids( // 성토끝 박스 옆선이 노선 흐름을 타게 된다(2026-08-26 사용자). BOX는 안 싣는다. const wingMode = end ? "route" : "station"; const wingAxis: [number, number] | undefined = end ? [base.leftX, base.leftY] : undefined; - solids.push({ chainage_m: chainageM, kind, polygons, rings, wing: wingMode, wingAxis }); + solids.push({ + chainage_m: chainageM, + kind, + key: anchor.key, + polygons, + rings, + wing: wingMode, + wingAxis, + }); const apron = anchor.apron; if (!apron || apron.floor.length < 3) continue; // 밑변은 **바닥판 끝면 그 자리**에서 시작한다 — 판을 밀어넣은 뿌리(rootX)에서 @@ -203,6 +215,7 @@ export function buildWingSolids( solids.push({ chainage_m: chainageM, kind: "basin", + key: anchor.apronKey, polygons: apronPolygons, rings: apronRings, wing: wingMode, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 9d49e317..a5674238 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -9,12 +9,14 @@ * ========================================================================== */ import { + computeMaxScale, lonLatToScreen, type Normalizer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { pointInRing } from "./B05_Profile_UI_Drainage_Parts"; +import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import type { createMapContextMenu } from "@ui/ui_template_context_menu"; @@ -29,6 +31,8 @@ export interface DrainageInteractParams { currentView: () => ViewState; getScale: () => number; setScale: (value: number) => void; + /** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */ + getMeta: () => VWorldMeta | null; getOffset: () => { x: number; y: number }; setOffset: (x: number, y: number) => void; scheduleDraw: () => void; @@ -55,7 +59,10 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { event.preventDefault(); const prevScale = params.getScale(); // 휠을 **당기면 확대**, 밀면 축소한다(2026-08-02 사용자 지시). B04 2D 지도와 같은 방향이다. - const scale = Math.min(16, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); + // 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시). + const view = currentView(); + const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16); + const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87))); params.setScale(scale); // 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작). const ratio = scale / prevScale; @@ -123,10 +130,11 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { let smallest = Number.POSITIVE_INFINITY; params.getBasins().forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; - const ring = basin.polygon_lonlat.map(([lon, lat]) => - lonLatToScreen(normalizer, view, lon, lat), + // 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않도록 링 전체로 판정한다. + const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) => + ring.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)), ); - if (!pointInRing(ring, x, y)) return; + if (!pointInRings(rings, x, y)) return; // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). if (basin.area_m2 < smallest) { smallest = basin.area_m2; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index dcce6cb4..58827809 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, @@ -12,6 +13,7 @@ import { } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, + MAP_STATION_INTERVAL_M, createNormalizer, prepareLayer, prepareMetricPolyline, @@ -26,8 +28,10 @@ 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 type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { mountDrainageToggles, @@ -38,7 +42,6 @@ import { fitViewToRoute, observeViewportSize, bindPipeContextMenu, - COLLAPSED_KEY, MAX_PANEL_WIDTH_RATIO, MIN_PANEL_WIDTH, renderBasinRows, @@ -119,6 +122,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let selectedBasin: number | null = null; // 측점 선택 마킹(계획선 위 누가거리). 유역이 없는 구조물 측점도 위치를 보여 준다. let markedChainage: number | null = null; + // 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 밖(종단 패널)이 + // 구조물 목록을 받을 때마다 넣어 준다. 여기서는 그리기만 하고 판정하지 않는다. + let intervalSpans: ReadonlyArray = []; // 마지막으로 밖에 알린 관 선택(누가거리) — 같은 값 재알림을 막는다. let lastNotifiedPipeChainage: number | null = null; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. @@ -128,7 +134,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scheduleDraw(); }, // 배치가 실제로 바뀐 순간(추가·삭제·이동 완료)에만 세부유역을 다시 나눈다. - () => void analyze(), + // 같은 순간에 **세션 초안**에도 담는다 — 예전에는 패널 메모리에만 있어 B06 으로 + // 넘어가 저장하면 편집이 사라졌다(2026-09-06 대응표 조사). + () => { + rememberPipes(); + void analyze(); + }, ); // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). @@ -200,6 +211,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra function updateImageTransform(): void { backgroundImage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; + // 크게 당기면 도엽 그림이 뭉개진다 — 흐림 보간을 끄고 픽셀을 그대로 보인다 + // (2026-09-04 사용자 지시). 축척 막대가 실제 크기를 알려 준다. + backgroundImage.style.imageRendering = scale > 4 ? "pixelated" : "auto"; } function draw(): void { @@ -244,6 +258,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor, pipeColor, markedChainage, + stationIntervalM: MAP_STATION_INTERVAL_M, + intervalSpans, }); updateImageTransform(); } @@ -384,6 +400,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)); @@ -488,6 +518,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra setScale: (value) => { scale = value; }, + getMeta: () => meta, getOffset: () => ({ x: offsetX, y: offsetY }), setOffset: (x, y) => { offsetX = x; @@ -520,7 +551,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", () => @@ -528,7 +559,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); // 상세 배수유역 정보는 페이지에 들어오면 바로 보여야 한다 — 저장값이 없으면 펼침이 기본이다 // (같은 페이지의 하단 종단 패널과 같은 규칙). - setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true"); + setCollapsed(readStateRaw("drainage-collapsed") === "true"); return { root, @@ -564,6 +595,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage = chainageM; scheduleDraw(); }, + setIntervalSpans(spans) { + intervalSpans = spans; + scheduleDraw(); + }, addPipe(chainageM, attributes) { // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. facilityStore.set(chainageM, attributes ?? null); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index f50a2375..8474aa0f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -11,6 +11,7 @@ import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; export interface DrainagePanel { root: HTMLElement; @@ -44,6 +45,9 @@ export interface DrainagePanel { selectPipeAtChainage: (chainageM: number | null) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; + /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). + * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ + setIntervalSpans: (spans: ReadonlyArray) => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 189e8713..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%는 남아야 한다. */ @@ -171,24 +173,8 @@ export function renderBasinRows( /** 사업지 좌표계(m) → 화면 px 변환기. 흐름 화살표와 강도 색칠이 같은 식을 쓴다 — * 둘이 어긋나면 색은 계획선 위인데 화살표만 밀린 것처럼 보인다. */ -export function createMetricProjector( - meta: VWorldMeta, - view: ViewState, -): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } { - const spanX = meta.width_meters || 1; - const spanY = meta.height_meters || 1; - const ax = view.mapRect.width * view.scale; - const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX; - const ay = view.mapRect.height * view.scale; - const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY; - return { - toScreen: (x, y) => [ - ((x - meta.x_min) / spanX) * ax + bx, - (1 - (y - meta.y_min) / spanY) * ay + by, - ], - pxPerMeter: ax / spanX, - }; -} +// 미터 좌표 → 화면 좌표 변환기는 B04 지도와 공용이다(정의처: MapRender). +export { createMetricProjector } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; /** 배관 우클릭 메뉴를 지도 뷰포트에 붙인다 — 마커 위면 삭제, 계획선 위면 추가. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 9df429ae..e5c92004 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -22,6 +22,7 @@ import { drawFilledRing, drawRidgeRing, drawRingBadge, + drawStationTicks, drawUpstreamLines, ringCenterOnScreen, } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; @@ -38,6 +39,7 @@ import { type DrainageLayer, } from "./B05_Profile_UI_Drainage_Parts"; import type { PipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; +import { drawRouteSpans, type RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; export interface DrainageScene { meta: VWorldMeta | null; @@ -64,6 +66,10 @@ export interface DrainageScene { pipeColor: (chainage: number, position: number) => string; /** 선택된 측점의 누가거리(m). 계획선 위 그 자리에 선택 표식을 그린다(null=없음). */ markedChainage: number | null; + /** 규칙 측점 간격(m) — 눈금·번호 표기 기준 (2026-09-04 사용자 지시). */ + stationIntervalM: number; + /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ + intervalSpans: ReadonlyArray; } export function drawDrainageScene( @@ -88,7 +94,7 @@ export function drawDrainageScene( } drawFilledRing( context, - { ring: basin.polygon_lonlat }, + { ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat }, normalizer, view, // 하나를 고르면 나머지는 옅게 물러난다. @@ -131,6 +137,10 @@ export function drawDrainageScene( return; } const projector = createMetricProjector(meta, view); + // 구간형 구조물 띠 — 계획선 바로 위, 강도 색칠 아래. 종단 레인의 띠와 같은 뜻이다. + if (scene.intervalSpans.length > 0) { + drawRouteSpans(context, scene.strengthSamples, scene.intervalSpans, projector.toScreen); + } // 유입 강도 색칠 — 계획선 위, 배관 마커 아래. 색띠는 B04 지도와 공용이다. if (scene.showStrength && scene.strength.length > 0) { drawStrengthLine(context, scene.strengthSamples, scene.strength, scene.maxStrength, (point) => @@ -182,6 +192,13 @@ export function drawDrainageScene( context.stroke(); context.restore(); } + // 측점 눈금·번호 — 관 마커 위, 유역 번호 아래 (2026-09-04 사용자 지시). B04 지도와 공용. + drawStationTicks(context, scene.strengthSamples, { + intervalM: scene.stationIntervalM, + pxPerMeter: projector.pxPerMeter, + toScreen: projector.toScreen, + avoidChainages: scene.pipeEditor.chainages(), + }); // 유역 번호 — 무엇에도 가리지 않게 맨 마지막. drawBadges(context, badges); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Spans.ts b/B05_Profile/B05_Profile_UI_Drainage_Spans.ts new file mode 100644 index 00000000..3b86acf9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Spans.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Spans.ts + * 배수유역도(평면)에서 **구간형 구조물이 놓인 자리**를 계획선 위에 띠로 그린다. + * + * 왜 (계획서 3-6) — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설은 종단에는 띠로 + * 보이는데 평면에는 아무 표시가 없었다. 「노선 위에 임의 구간을 얹는 부품이 없다」가 + * 그동안의 걸림돌이었는데, 계획선 표본(`strengthSamples`)이 **1m 간격이라 배열 인덱스가 + * 곧 누가거리**여서 구간 → 화면 선은 잘라 붙이기만 하면 된다. + * + * 그리는 자리 — **계획선 바로 위, 강도 색칠·마커 아래**. 굵고 반투명해서 계획선을 덮지 + * 않는다. 이름은 안 적는다(종단에 이미 있고, 지도에 글자를 얹으면 눈금·유역 번호와 겹친다). + * ========================================================================== */ + +import type { RoutePoint } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; + +/** 계획선 위에 얹을 구간 하나 — 누가거리 두 값과 색. */ +export interface RouteSpanBand { + startM: number; + endM: number; + /** 구조물 레지스트리의 표시색(`style.color`). */ + color: string; +} + +/** 띠 굵기(px) — 계획선(2px 안팎)보다 확실히 굵되 유역 채움을 가리지 않는 값. */ +const BAND_WIDTH_PX = 7; + +/** + * 구간 띠를 그린다. 표본이 없거나 구간이 비면 아무 것도 하지 않는다. + * + * 인덱스는 **누가거리(m)** 다 — 표본이 1m 간격이라 그렇다(`resampleRoute`). 범위를 벗어난 + * 값은 잘라 쓰고, 한 점짜리 구간(시작=끝)은 짧은 토막으로라도 보이게 한 칸을 준다. + */ +export function drawRouteSpans( + context: CanvasRenderingContext2D, + samples: ReadonlyArray, + spans: ReadonlyArray, + toScreen: (x: number, y: number) => [number, number], +): void { + if (samples.length < 2 || spans.length === 0) return; + const last = samples.length - 1; + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.lineWidth = BAND_WIDTH_PX; + for (const span of spans) { + const from = Math.max(0, Math.min(last, Math.floor(Math.min(span.startM, span.endM)))); + const to = Math.max(from + 1, Math.min(last, Math.ceil(Math.max(span.startM, span.endM)))); + context.beginPath(); + for (let index = from; index <= to; index += 1) { + const [x, y] = toScreen(samples[index].x, samples[index].y); + if (index === from) context.moveTo(x, y); + else context.lineTo(x, y); + } + context.strokeStyle = span.color; + context.globalAlpha = 0.45; + context.stroke(); + context.globalAlpha = 1; + } + context.restore(); +} + +/** + * 구조물 정본 + 타입 레지스트리 → 띠 목록. **종단 레인과 같은 자료**를 본다(계획서 3-6). + * + * · 구간형(`interval`)만 띠가 된다 — 점형 시설은 마커로 이미 보인다. + * · 색은 레지스트리 표시색을 그대로 쓴다(여기서 새로 정하지 않는다). + * · 시작·끝이 없으면 기준 측점으로 대신한다. 그것도 없으면 뺀다. + */ +export function routeSpansFromStructures( + structures: ReadonlyArray, + types: ReadonlyArray, +): RouteSpanBand[] { + const colorOf = new Map(types.map((type) => [type.type_id, type.style?.color])); + const spans: RouteSpanBand[] = []; + for (const item of structures) { + if (item.placement !== "interval") continue; + const start = item.start_m ?? item.chainage_m ?? null; + const end = item.end_m ?? item.chainage_m ?? null; + if (start === null || end === null) continue; + spans.push({ + startM: Math.min(start, end), + endM: Math.max(start, end), + color: colorOf.get(item.type_id) ?? "#8a8a8a", + }); + } + return spans; +} diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index 371a9842..f115c28c 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -43,8 +43,16 @@ export interface SectionStationMarker { structure?: string; } -/** 규칙 측점 라벨을 몇 칸마다 달지. 전부 달면 글자가 겹쳐 도면을 못 읽는다. */ -const STATION_LABEL_STEP = 5; +/** + * 규칙 측점 라벨 솎기 — 라벨은 **전 측점에 만들어 두고** 카메라 거리로 골라 보인다 + * (2026-09-04 사용자 지시 「전체 라벨이 있으면 좋겠음」). 멀면 글자가 겹치므로 + * 5칸 → 2칸 → 전부로 단계를 올린다. 경계는 카메라~시점거리(m). + */ +const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [ + { within: 150, step: 1 }, + { within: 400, step: 2 }, + { within: Infinity, step: 5 }, +]; // 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. const UPHILL_LAMP_COLOR = 0xf97316; @@ -87,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, @@ -246,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( @@ -266,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( @@ -293,28 +332,42 @@ export function createRouteMarkers( * * BP·EP — 시·종점은 항상. 이름을 앞에 붙여 어느 끝인지 바로 읽히게 한다. * 구조물(비정규) — 측점번호 + 구조물 이름(배관 등). - * 5측점 배수 — 규칙 측점은 5칸마다만. 전부 달면 글자가 겹쳐 도면을 못 읽는다. + * 규칙 측점 — 전부 만든다. 몇 개를 보일지는 카메라 거리가 정한다(`LABEL_LOD`). * * 측점번호는 라벨 표기(`측점번호+잔여거리`)에서 되짚는다 — 측점간격은 렌더러가 모른다. - * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 배수 판정에서 뺀다. + * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 솎기 판정에서 뺀다 + * (`number: null` = 거리와 무관하게 항상 보임). */ - function stationLabelText(station: SectionStationMarker, intervalM: number): string | null { + function stationLabelText( + station: SectionStationMarker, + intervalM: number, + ): { text: string; number: number | null } | null { const chainage = station.chainage_m; if (!Number.isFinite(chainage)) return null; // 표기는 종단 그래프·도면 테이블과 **같은 규칙**(`측점번호+잔여거리`)을 쓴다. // 서버가 내려주는 `label`(`STA.0+000.000`)을 그대로 쓰면 화면마다 표기가 갈린다. const text = stationLabel(chainage as number, intervalM); - if (station.kind === "bp") return `BP ${text}`; - if (station.kind === "ep") return `EP ${text}`; + if (station.kind === "bp") return { text: `BP ${text}`, number: null }; + if (station.kind === "ep") return { text: `EP ${text}`, number: null }; if (station.kind === "irregular") { const structure = station.structure?.trim(); - return structure ? `${text} ${structure}` : text; + return { text: structure ? `${text} ${structure}` : text, number: null }; } const safeInterval = intervalM > 0 ? intervalM : 1; const stationNumber = Math.round((chainage as number) / safeInterval); const remainder = (chainage as number) - stationNumber * safeInterval; if (Math.abs(remainder) > 0.05) return null; - return stationNumber % STATION_LABEL_STEP === 0 ? text : null; + return { text, number: stationNumber }; + } + + /** 지금 솎기 단계(몇 칸마다 보일지). 카메라 거리로 바뀐다. */ + let labelStep = LABEL_LOD[LABEL_LOD.length - 1].step; + + function applyLabelStep(): void { + stationLabelGroup.children.forEach((child) => { + const number = (child.userData as { stationNumber?: number | null }).stationNumber; + child.visible = typeof number !== "number" || number % labelStep === 0; + }); } /** @@ -405,9 +458,11 @@ export function createRouteMarkers( // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). - const labelText = stationLabelText(station, stationIntervalM); - if (labelText) { - stationLabelGroup.add(stationLabelSprite(labelText, modelToScene(center, bounds))); + const label = stationLabelText(station, stationIntervalM); + if (label) { + const sprite = stationLabelSprite(label.text, modelToScene(center, bounds)); + sprite.userData.stationNumber = label.number; + stationLabelGroup.add(sprite); } (["left", "right"] as const).forEach((side, endIndex) => { @@ -435,6 +490,8 @@ export function createRouteMarkers( stationGroup.add(lampHit); }); }); + // 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다. + applyLabelStep(); // 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다. syncSelectionPin(); } @@ -537,6 +594,13 @@ export function createRouteMarkers( setStationLabelsVisible(visible: boolean) { stationLabelGroup.visible = visible; }, + /** 카메라~시점 거리(m)로 규칙 측점 라벨을 솎는다. 구조물·BP·EP 는 늘 보인다. */ + updateLabelDetail(distanceM: number) { + const step = (LABEL_LOD.find((lod) => distanceM < lod.within) ?? LABEL_LOD[0]).step; + if (step === labelStep) return; + labelStep = step; + applyLabelStep(); + }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 6ffbde49..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,17 +26,31 @@ 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, + wireStructurePick, + writeStructurePick, +} from "./B05_Profile_UI_Structure_Pick_Session"; import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations"; +import { + rememberRockBoundaryDefault, + rememberStandardDefaults, +} from "../B06_Section/B06_Section_UI_Standard_Panel"; import { fetchSectionContext, type SectionDetailResponse, } 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, @@ -44,12 +58,17 @@ import { type PageActionContext, } from "./B05_Profile_UI_Page_Actions"; import "./B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "./B05_Profile_UI_Style_Table.css"; +import "./B05_Profile_UI_Style_Drainage.css"; import "./B05_Profile_UI_Style_Structures.css"; import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, interpolateIrregularStations, + loadUphillOverrides, restorePoints, + saveUphillOverrides, toBounds, toGradeClass, } from "./B05_Profile_UI_Page_Helpers"; @@ -58,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) { @@ -105,6 +127,7 @@ export async function renderB05Route(root: HTMLElement): Promise { options: pipe.options, })), ); + restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }, // 그래프 측점선은 이제 배관(계곡 통과 시설) 투영뿐이다 — 수동 구조물은 // 서클마크·구조물 배치 목록이 담당한다(2026-08-17 컨테이너 병합). @@ -128,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); @@ -153,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; @@ -167,21 +196,8 @@ export async function renderB05Route(root: HTMLElement): Promise { /* ── 측점 상단측(=측구 방향) 사용자 변경분 ───────────────────────────── * solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가 * 경로 확정 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */ - const uphillSessionKey = `b05:uphill:${activeProjectId}`; - const uphillOverrides = new Map(); + const uphillOverrides = loadUphillOverrides(activeProjectId); const uphillKey = (chainage: number): string => chainage.toFixed(3); - try { - const raw = window.sessionStorage.getItem(uphillSessionKey); - if (raw) { - Object.entries(JSON.parse(raw) as Record).forEach( - ([chainage, side]) => { - if (side === "left" || side === "right") uphillOverrides.set(chainage, side); - }, - ); - } - } catch { - /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ - } const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), @@ -197,17 +213,19 @@ export async function renderB05Route(root: HTMLElement): Promise { // 배관이면 배수유역 마커 선택 + 부속 옵션 폼까지 연다(2026-08-17 전역 선택 동기화). selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), + selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), }); + /** 세션에 남은 구조물 선택을 되살린다 — B06에서 고른 것도 그대로 이어 받는다 + * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ + function restorePick(): void { + if (panel.structures.hasSelection()) return; + const pick = viewer.structurePick; + restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe); + } + function persistUphillOverrides(): void { - try { - window.sessionStorage.setItem( - uphillSessionKey, - JSON.stringify(Object.fromEntries(uphillOverrides)), - ); - } catch { - /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ - } + saveUphillOverrides(activeProjectId, uphillOverrides); } /* ── 최신 경로/설정값 세션 캐시 ──────────────────────────────────────── @@ -229,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 사용자 확정) — 이동은 막지 않는다. @@ -253,6 +306,7 @@ export async function renderB05Route(root: HTMLElement): Promise { onSurfaceGrayscale: viewer.setSurfaceGrayscale, onView: viewer.setView, onResetView: () => viewer.setView("top"), + onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. onCorridorRefresh: async () => { if (!currentSectionDetail || !latest?.route?.id) return; @@ -268,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, @@ -282,6 +340,7 @@ export async function renderB05Route(root: HTMLElement): Promise { profilePanel.drainage.updatePipeFacility(from, to, attributes), onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilitySelect: (chainage) => { + writeStructurePick(activeProjectId, chainage); // 목록 선택도 B06으로 이어진다. if (selectionSyncing) return; selectionSyncing = true; try { @@ -303,6 +362,8 @@ export async function renderB05Route(root: HTMLElement): Promise { selectionSyncing = false; } }, + // 3D·종단·유역도에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). + onStructureReveal: () => layout.setOptionsOpen(true), onStructureSelect: (structure) => { if (selectionSyncing) return; selectionSyncing = true; @@ -314,6 +375,8 @@ export async function renderB05Route(root: HTMLElement): Promise { }, }); + wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); + /** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정). * 지형 구분·등급·기울기 세부 입력은 종단 그래프의 위반 판정 기준까지 **즉시** * 갈아 끼운다(2026-08-19 사용자 지시 6 — 예전에는 재계산·재진입 때만 반영). */ @@ -410,14 +473,16 @@ 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, - center_z: - design !== null && station.center_z !== null - ? Math.max(station.center_z, design) - : station.center_z, + // 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적). + // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. + // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. + center_z: design !== null && station.center_z !== null ? design : station.center_z, uphill_side: uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, }; @@ -455,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)); } } @@ -494,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) { @@ -572,12 +642,11 @@ export async function renderB05Route(root: HTMLElement): Promise { }; /* ── 진입 로딩 ───────────────────────────────────────────────────────── - * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 - * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 - * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ - const LOAD_STEP_COUNT = 5; - // 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다 - // (2026-08-01 사용자 지시). + * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). + * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가 + * 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 하고, 3D는 뒤에서 올린 뒤 알린다. + * 서클은 3D 뷰포트 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ + const LOAD_STEP_COUNT = 4; const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true }); viewer.root.append(progress.root); let loadedSteps = 0; @@ -609,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), ]); @@ -632,48 +703,62 @@ export async function renderB05Route(root: HTMLElement): Promise { haulLimits: sectionContext.haul_equipment_limits, naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined, }); + // 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」 + // 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다 + // (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다. + rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section); + rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m); restorePanel(latestResponse); renderLatest(latestResponse); latest = latestResponse; advanceLoading("확정 지표면 모델을 확인하는 중…"); - // ③ 확정 지표면 모델 목록. - const models = await listSurfaceModels(activeProjectId); - confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - advanceLoading("종단면 자료를 불러오는 중…"); - - // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. - if (latestResponse.route) await restoreSections(latestResponse.route.id); - advanceLoading("3D 지형을 불러오는 중…"); - - // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. + // ③ 확정 지표면 — 모델 id·범위를 한 번에 받는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + confirmedSurface = confirmed.model_id === null ? null : confirmed; if (!confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; - } else { - // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. - const confirmed = await fetchConfirmedSurface(activeProjectId); - if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); - await viewer.loadSurface( - activeProjectId, - confirmedSurface.id, - latestResponse.surface_params.method, - latestResponse.surface_params.smooth, - latestResponse.surface_params.contour_interval_m, - toBounds(confirmed.bounds), - ); - // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. - renderLatest(latestResponse); - if (currentSectionDetail) renderStationLines(currentSectionDetail); } + advanceLoading("종단면 자료를 불러오는 중…"); + + // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. + if (latestResponse.route) await restoreSections(latestResponse.route.id); // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); + // 캐시로 그렸다면 이제 뒤에서 신선도만 확인한다 — 화면은 이미 서 있으므로 기다리지 + // 않는다. 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다. + if (cachedLatest) void verifyLatestFreshness(latestResponse); } catch (error) { showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); } finally { progress.remove(); restoring = false; + restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } + + // ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. + void (async () => { + const [surface, current] = [confirmedSurface, latest]; + if (!surface || !current) return; + try { + // 범위는 ③에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. + if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + activeProjectId, + surface.model_id as number, + current.surface_params.method, + current.surface_params.smooth, + current.surface_params.contour_interval_m, + toBounds(surface.bounds), + ); + renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + if (currentSectionDetail) renderStationLines(currentSectionDetail); + showToast("3D 지형 준비 완료", "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } + })(); } diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index d54c74d5..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,13 +24,15 @@ 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"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; @@ -45,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; @@ -76,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), @@ -130,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() @@ -148,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() @@ -201,11 +205,14 @@ 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(); showToast(L("B05_Route_Reset_Success"), "success"); navigateTo(ROUTES.B05_PROFILE); } catch (error) { diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index 729ebf91..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, @@ -242,3 +243,33 @@ export const FACILITY_NAMES: Record = { ford_bridge: "세월교", revetment: "기슭막이", }; + +/* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── + * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. + * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ +/** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */ +export function loadUphillOverrides(projectId: string): Map { + const overrides = new Map(); + try { + 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 { + /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ + } + return overrides; +} + +/** 상단측 변경분을 세션에 적는다. 저장 실패는 무시한다(값은 메모리에 남는다). */ +export function saveUphillOverrides( + projectId: string, + overrides: ReadonlyMap, +): void { + try { + 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 7e58e2c9..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 횡단 페이지로 이동만. */ @@ -104,6 +106,8 @@ interface PanelCallbacks { /** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */ onSurfaceGrayscale: (grayscale: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; + /** 직교/원근 전환 — 탑뷰에서 크기를 정밀 대조할 때만 직교로 본다(2026-09-04 사용자 지시). */ + onProjection: (kind: "perspective" | "ortho") => void; onResetView: () => void; /** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자 * 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */ @@ -116,6 +120,8 @@ interface PanelCallbacks { onStructuresChange: (structures: StructureInstance[]) => void; /** 구조물을 목록에서 선택/해제할 때 해당 구조물(또는 null). */ onStructureSelect: (structure: StructureInstance | null) => void; + /** 바깥(3D·종단·유역도)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ + onStructureReveal?: () => void; /** 계곡 통과 시설 추가·수정·삭제·선택 — 관 지점 정본(배수유역 패널) 경유(2026-08-17 통합). */ onPipeFacilityAdd: (chainageM: number, attributes: FacilityAttributes) => void; onPipeFacilityUpdate: ( @@ -218,6 +224,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); + // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 + // 가리킨다(누르면 그쪽으로 간다). + let projection: "perspective" | "ortho" = "perspective"; + const projectionButton = button( + "직교로", + () => { + projection = projection === "perspective" ? "ortho" : "perspective"; + projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; + callbacks.onProjection(projection); + }, + "glass", + ); + viewButtons.append(projectionButton); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( @@ -285,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"); @@ -370,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, @@ -398,6 +432,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ], }); const terrainType = terrainField.select; + // 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라 + // 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다. + terrainType.value = "special"; // 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로 // 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다. // 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관 @@ -477,6 +514,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, + onReveal: () => callbacks.onStructureReveal?.(), }); /** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면 @@ -578,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 2c84fa49..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 { @@ -112,6 +116,9 @@ export interface AlignmentEdits { export interface ProfileAlignment { schema_version: number; policy: AlignmentPolicy; + /** 전체 측점 폴리라인이면 true — 사용자가 R을 지정한 변화점에만 라운드를 넣는다 + * (2026-09-02 사용자 확정, 백엔드 `build_alignment(only_explicit_curves=)` 와 같은 값). */ + only_explicit_curves?: boolean; base_pvi: AlignmentNode[]; edits: AlignmentEdits; pvi: AlignmentPvi[]; @@ -127,6 +134,8 @@ export interface ProfileAlignment { /** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */ export interface AlignmentBase { policy: AlignmentPolicy; + /** 라운드를 사용자가 지정한 변화점에만 넣을지 — 저장본에서 그대로 물려받는다. */ + onlyExplicitCurves: boolean; basePvi: AlignmentNode[]; chainage: number[]; ground: number[]; @@ -162,6 +171,7 @@ function interpolate(xs: number[], ys: number[], value: number): number { export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { return { policy: alignment.policy, + onlyExplicitCurves: alignment.only_explicit_curves === true, basePvi: alignment.base_pvi.map((node) => ({ ...node })), chainage: alignment.samples.map((sample) => sample.chainage_m), ground: alignment.samples.map((sample) => sample.ground_elevation_m), @@ -207,6 +217,7 @@ function buildCurves( policy: AlignmentPolicy, curveRadii: Record, warnings: string[], + onlyExplicit = false, ): WorkingCurve[] { const curves: WorkingCurve[] = []; const skipDelta = policy.curve_skip_delta_pct / 100; @@ -225,6 +236,8 @@ function buildCurves( // 기본 L이 없는 옛 저장분만 R 기준으로 되돌아간다. const requested = curveRadii[key]; const hasRequested = Number.isFinite(requested) && requested > 0; + // 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다. + if (onlyExplicit && !hasRequested) continue; const fallbackLength = Number.isFinite(policy.default_curve_length_m) ? (policy.default_curve_length_m as number) : policy.default_curve_radius_m * Math.abs(delta); @@ -309,7 +322,13 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof const nodes = resolvePvi(base, edits.station_offsets); const pviS = nodes.map((node) => node.chainage_m); const pviZ = nodes.map((node) => node.elevation_m); - const curves = buildCurves(nodes, base.policy, edits.curve_radii, warnings); + const curves = buildCurves( + nodes, + base.policy, + edits.curve_radii, + warnings, + base.onlyExplicitCurves === true, + ); const segments: AlignmentSegment[] = []; for (let index = 0; index < nodes.length - 1; index += 1) { @@ -411,6 +430,7 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof return { schema_version: 1, policy: base.policy, + only_explicit_curves: base.onlyExplicitCurves === true, base_pvi: base.basePvi, edits, pvi: pviRows, diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 92ab128b..16dbea88 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -4,9 +4,9 @@ * * 표시 항목(별표2 근거): * 최대 기울기 x.x% (상한 y%) — Ⅰ.2.라: 설계속도·지형별 상한 준수 판정 - * 불균형 z% / 허용 w% — Ⅰ.1.나.(4)(다): 시공계획고 절·성토 균형 * 곡선 필요 n곳 — Ⅰ.2.마: 대수차 5% 초과 변화점의 종단곡선 삽입 - * 절·성토 면적은 하단 유토곡선이 부피로 더 정확히 보여 주고, 변화점·종단곡선 개수와 + * 절·성토 균형은 **하단 유토곡선**이 횡단 기준 부피(㎥)로 보여 준다 — 여기 있던 종단 기준 + * 불균형(㎡ 비율)은 실제 토량이 아니라 의미가 없어 뺐다(2026-09-03). 변화점·종단곡선 개수와 * 기본 곡선길이 L은 판단 기준이 없어 뺐다(2026-08-19 사용자 지적 + 지식DB 대조). * [저장선 복원]은 상단에서 내려 그래프 조작부로 옮겼다. * @@ -15,7 +15,7 @@ * ========================================================================== */ import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; -import { minCoverWarningText, type MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; +import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; export interface BalanceBarParams { /** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */ @@ -30,14 +30,25 @@ export interface BalanceBarParams { hasIrregularStations: boolean; /** 저장되지 않은 편집이 있는지. */ dirty: boolean; + /** 요약줄 맨 앞에 놓는 도구줄(되돌리기·직선화·쉬프트·틸팅) — 2026-09-02. */ + tools?: HTMLElement; /** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */ 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; } export function renderBalanceBar(params: BalanceBarParams): void { params.balanceBar.replaceChildren(); + // 도구줄은 계획선이 없을 때도 둔다 — 되돌리기는 계획선 밖 조작(구조물 등)도 되돌린다. + if (params.tools) params.balanceBar.append(params.tools); if (!params.alignment) { if (params.legacyAlignment) { const note = document.createElement("span"); @@ -46,10 +57,11 @@ export function renderBalanceBar(params: BalanceBarParams): void { "⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요."; params.balanceBar.append(note); } + if (params.trailing) params.balanceBar.append(params.trailing); return; } const { alignment } = params; - const { balance, policy, violations } = alignment; + const { policy, violations } = alignment; // 최대 종단기울기 — 법정 상한 대비가 이 화면의 첫 판정 항목이다(별표2 Ⅰ.2.라). const steepest = alignment.segments.reduce( (worst, segment) => Math.max(worst, Math.abs(segment.grade_percent)), @@ -66,18 +78,27 @@ export function renderBalanceBar(params: BalanceBarParams): void { `${steepest.toFixed(1)} % / 상한 ${policy.max_grade_pct.toFixed(1)} %`, violations.length ? "over" : undefined, ], - [ - "절·성토 불균형", - `${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`, - balance.within_tolerance ? undefined : "over", - ], ]; + // 절·성토 불균형은 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-08-23 사용자 지시) — 관경·구체높이 + 토피 0.5m를 - // 밑도는 측점이 있으면 경고한다. 계획선을 대신 올려 주지는 않는다(사용자 판단). - const coverWarning = minCoverWarningText(params.minCoverViolations ?? []); - if (coverWarning) entries.push(["횡단배수 최소고", coverWarning, "over"]); + // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 + // `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다. const editedCount = Object.keys(alignment.edits.station_offsets).length; if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); entries.forEach(([label, value, tone]) => { @@ -88,13 +109,14 @@ export function renderBalanceBar(params: BalanceBarParams): void { item.append(caption, document.createTextNode(value)); // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — // 같은 사실을 두 번 적지 않는다(2026-08-19 재편). - if (label === "횡단배수 최소고" && params.minCoverViolations?.length) { - item.title = params.minCoverViolations - .map( - (entry) => - `${entry.chainage_m.toFixed(1)}m ${entry.label}: 계획고 ${entry.planned_m.toFixed(2)} < 최소 ${entry.required_m.toFixed(2)} (부족 ${entry.shortfall_m.toFixed(2)}m)`, - ) - .join("\n"); + 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 @@ -106,29 +128,14 @@ export function renderBalanceBar(params: BalanceBarParams): void { } params.balanceBar.append(item); }); - if (params.edited || params.hasIrregularStations) { - const reset = document.createElement("button"); - reset.type = "button"; - reset.className = "b05-route-profile__balance-reset"; - // 이 버튼이 지우는 것은 **화면에 쌓인 편집 델타**다(계획선 변화점 이동·비정규 - // 측점). [저장]을 거치면 그 편집이 정본에 반영되므로 결과적으로 "마지막 저장 - // 상태"가 되는 것뿐, 저장 지점으로 되돌아가는 기능이 아니다. 최초 자동 계산 - // 상태로의 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다(2026-08-19 정정). - // 자리도 값 칩들 뒤 — 편집 상태(미저장 배지) 옆이 맥락에 맞다. - reset.textContent = "편집 되돌리기"; - reset.title = - "화면에서 수정한 계획선 변화점과 추가한 비정규 측점을 지웁니다.\n" + - "이미 저장한 내용은 정본에 반영돼 있어 그대로 남습니다.\n" + - "최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요."; - reset.addEventListener("click", () => { - params.onResetAll(); - }); - params.balanceBar.append(reset); - } + // [편집 되돌리기] 버튼은 2026-09-02 사용자 지시로 삭제했다 — 한 단계씩 되돌리는 + // undo/redo가 대신하며, 최초 자동 계산 상태 복귀는 좌측 하단 [초기화]가 맡는다. if (params.dirty) { const badge = document.createElement("span"); badge.className = "b05-route-profile__balance-item is-unsaved"; badge.textContent = "미저장 (확정 시 반영)"; params.balanceBar.append(badge); } + // 줌 조작구는 늘 오른쪽 끝 — 그래프 우측 상단 자리다(2026-09-04 사용자 지시). + if (params.trailing) params.balanceBar.append(params.trailing); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index d762e5b5..e2fba671 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -7,19 +7,15 @@ * * 버튼 구성 (평시 투명, 패널 hover 시 노출): * - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다. - * - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동). - * - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다. * - * 측점 버튼과 구간 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 - * 측점 라벨에 가려 보이지 않기 때문이다. 자리가 겹칠 땐 측점 버튼을 수직선 위에 고정하고 - * 구간 버튼만 옆으로 비킨다. + * 구간 ⇧⇩(평행이동)과 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — 구간 조작은 + * [직선화]+[쉬프트]가, 되돌리기는 undo/redo가 대신한다. + * + * 측점 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 측점 + * 라벨에 가려 보이지 않기 때문이다. * ========================================================================== */ -import type { - AlignmentEdits, - AlignmentSegment, - ProfileAlignment, -} from "./B05_Profile_UI_Profile_Alignment"; +import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment"; const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; @@ -27,8 +23,8 @@ const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; /** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */ -const HOLD_DELAY_MS = 500; -const HOLD_INTERVAL_MS = 100; +export const HOLD_DELAY_MS = 500; +export const HOLD_INTERVAL_MS = 100; /** * 길게 누르는 동안 같은 동작을 반복한다. @@ -67,8 +63,13 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () = export interface ProfileEditStore { edits(): AlignmentEdits; replace(next: AlignmentEdits): void; - /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */ + /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. + * 이때의 값이 [초기화]의 기준점이 된다. */ markSaved(): void; + /** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */ + restoreSaved(): void; + /** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */ + canRestoreSaved(): boolean; resetStation(chainageM: number): void; resetAll(): void; /** 아직 서버에 반영되지 않은 변경이 있는가. */ @@ -85,7 +86,27 @@ export interface ProfileEditStore { * 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다. */ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null { - return readDraft(`${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`); + return routeId === null ? null : readDraft(`${DRAFT_KEY_PREFIX}:${routeId}`); +} + +/** + * 계획선 편집 초안을 모두 버린다 — [초기화]가 부른다. + * + * 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에 + * 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: + * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). + */ +export function clearAlignmentDrafts(): void { + try { + const keys: string[] = []; + for (let index = 0; index < sessionStorage.length; index += 1) { + const key = sessionStorage.key(index); + if (key?.startsWith(`${DRAFT_KEY_PREFIX}:`)) keys.push(key); + } + keys.forEach((key) => sessionStorage.removeItem(key)); + } catch { + // 세션 저장소를 못 쓰는 환경이면 남길 초안도 없다. + } } function readDraft(storageKey: string): AlignmentEdits | null { @@ -108,18 +129,28 @@ function readDraft(storageKey: string): AlignmentEdits | null { * 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이 * 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면 * 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. + * + * routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에 + * 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다. */ export function createProfileEditStore( routeId: number | null, saved: AlignmentEdits, onChange: () => void, ): ProfileEditStore { - const storageKey = `${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`; - const draft = readDraft(storageKey); + const storageKey = routeId === null ? null : `${DRAFT_KEY_PREFIX}:${routeId}`; + const draft = storageKey ? readDraft(storageKey) : null; let current = draft ?? saved; let unsaved = draft !== null; + /** + * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). + * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 + * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. + */ + let savedBaseline = saved; function dropDraft(): void { + if (!storageKey) return; try { sessionStorage.removeItem(storageKey); } catch { @@ -131,7 +162,7 @@ export function createProfileEditStore( current = next; unsaved = true; try { - sessionStorage.setItem(storageKey, JSON.stringify(current)); + if (storageKey) sessionStorage.setItem(storageKey, JSON.stringify(current)); } catch { // 초안 보관 실패는 편집을 막지 않는다. } @@ -143,8 +174,16 @@ export function createProfileEditStore( replace: commit, markSaved() { unsaved = false; + savedBaseline = current; dropDraft(); }, + restoreSaved() { + current = savedBaseline; + unsaved = false; + dropDraft(); + onChange(); + }, + canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline), resetStation(chainageM) { const key = chainageKey(chainageM); const stationOffsets = { ...current.station_offsets }; @@ -173,17 +212,19 @@ export interface EditOverlayOptions { width: number; x: (chainageM: number) => number; step: number; - /** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼(+원복) 버튼을 단다. */ + /** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼ 버튼을 단다. */ irregularStations?: Array<{ chainage_m: number }>; onStation: (chainageM: number, delta: number) => void; - onSegment: (segment: AlignmentSegment, delta: number) => void; - onResetStation: (chainageM: number) => void; - /** 구간 평행이동 편집을 자동 선형으로 원복(양 끝 오프셋 삭제). */ - onResetSegment: (segment: AlignmentSegment) => void; - /** 쉬프트 가능 구간 판정 — false면 그 구간의 ⬆⬇를 만들지 않는다(힌지 부족). */ - canShift?: (segment: AlignmentSegment) => boolean; } +/** + * 두 측점 목록이 **같은 측점**을 가리키는 것으로 볼 누가거리 차(m). + * + * 사이드바 구조물 목록은 표기 규칙(`n+ 0.0`)대로 0.1m 로 반올림한 값을 들고 있어 종단 + * 정본의 원값과 최대 0.05m 어긋난다. 실제로 이만큼 붙은 별개 측점은 없다. + */ +const SAME_STATION_TOLERANCE_M = 0.1; + function overlayButton( repeater: { start: (action: () => void) => void }, className: string, @@ -235,8 +276,7 @@ function planElevationAtSample(alignment: ProfileAlignment, chainageM: number): /** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { - const { alignment, width, x, step, onStation, onSegment, onResetStation, onResetSegment } = - options; + const { alignment, width, x, step, onStation } = options; const irregular = options.irregularStations ?? []; const layer = document.createElement("div"); layer.className = "b05-profile-edit"; @@ -252,19 +292,28 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { * 측점선 자체는 그대로라 버튼만 살짝 비켜선다. * * 확정을 거친 구조물 측점은 종단 정본(alignment.stations)에 **이미 병합**돼 있어 - * 사이드바 목록(irregular)과 이중으로 잡힌다 — 같은 chainage(소수 3자리)는 한 번만 - * 버튼을 단다(2026-08-04 사용자 보고: 구조물 측점 버튼이 2개씩 생김). + * 사이드바 목록(irregular)과 이중으로 잡힌다 — 겹치면 한 번만 버튼을 단다 + * (2026-08-04 사용자 보고: 구조물 측점 버튼이 2개씩 생김). + * + * 겹침 판정은 **거리**로 한다. 두 목록의 누가거리가 소수 3자리까지 같지 않기 때문이다 — + * 종단 정본은 원값(`85.594514`), 사이드바 목록은 표기 규칙대로 0.1m 로 반올림한 값 + * (`85.6`)을 들고 있어 키 비교로는 서로 다른 측점으로 잡혔다. 그 결과 버튼이 두 벌 생기고, + * 사이드바 쪽 버튼을 누르면 정본 측점에서 몇 mm 떨어진 **가짜 변화점**이 승격돼 그 구간 + * 기울기가 수천 %로 튀었다(2026-09-03 사용자 보고 · 실측 11,858%). */ - const alignmentStationKeys = new Set( - alignment.stations.map((station) => chainageKey(station.chainage_m)), - ); const stationPlacements = [ ...alignment.stations.map((station) => ({ chainage: station.chainage_m, plan: station.plan_elevation_m, })), ...irregular - .filter((station) => !alignmentStationKeys.has(chainageKey(station.chainage_m))) + .filter( + (station) => + !alignment.stations.some( + (merged) => + Math.abs(merged.chainage_m - station.chainage_m) <= SAME_STATION_TOLERANCE_M, + ), + ) .map((station) => ({ chainage: station.chainage_m, plan: planElevationAtSample(alignment, station.chainage_m), @@ -281,31 +330,6 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { }; })(), ); - const stationXs = stationPlacements.map((entry) => entry.left); - - /** 이미 자리를 잡은 구간 버튼 x — 구간끼리도 겹치지 않게 기억해 둔다. */ - const segmentXs: number[] = []; - - /** - * 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다. - * 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고 - * 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다). - * - * 한 번만 밀면 밀어낸 자리에 또 다른 측점·구간 버튼이 있을 때 그대로 겹친다 - * (2026-08-17 사용자 보고). 빈자리가 나올 때까지 반복해서 밀어낸다. - */ - function avoidStations(center: number): number { - let placed = center; - for (let guard = 0; guard < 40; guard += 1) { - const blocking = [...stationXs, ...segmentXs] - .filter((px) => Math.abs(px - placed) < BUTTON_CLEARANCE_PX) - .sort((left, right) => right - left)[0]; - if (blocking === undefined) break; - placed = blocking + BUTTON_CLEARANCE_PX; - } - segmentXs.push(placed); - return placed; - } // 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도 // `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다. // left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다. @@ -327,60 +351,14 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { down.style.left = `${left - BUTTON_HALF_PX}px`; layer.append(up, down); - if (!isEdited) return; - const offset = alignment.edits.station_offsets[chainageKey(chainageM)]; - const reset = overlayButton( - repeater, - "is-reset", - "↺", - `${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`, - () => onResetStation(chainageM), - ); - reset.style.left = `${left - BUTTON_HALF_PX}px`; - layer.append(reset); + // 측점 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — undo/redo 로 대체한다. + void isEdited; } stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left)); - alignment.segments.forEach((segment) => { - const left = x(segment.from_m); - const right = x(segment.to_m); - if (right - left < 36) return; - // 힌지(안쪽 미틸트 측점 2개)를 못 만드는 구간은 쉬프트 대상이 아니다(2026-08-23). - if (options.canShift && !options.canShift(segment)) return; - const center = avoidStations((left + right) / 2); - const label = - `구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` + - `(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`; - // 구간 이동 화살표는 속이 찬 글리프(⬆⬇)를 쓴다(2026-08-04 사용자 지시 — ⇧⇩는 윤곽선뿐이라 - // 흐릿했다). ︎(텍스트 표기 선택자)로 이모지 컬러 렌더링을 막아 방향색이 살게 한다. - const up = overlayButton(repeater, "is-segment is-up", "⬆︎", `${label} — ${step}m 올림`, () => - onSegment(segment, step), - ); - up.style.left = `${center - BUTTON_HALF_PX}px`; - const down = overlayButton( - repeater, - "is-segment is-down", - "⬇︎", - `${label} — ${step}m 내림`, - () => onSegment(segment, -step), - ); - down.style.left = `${center - BUTTON_HALF_PX}px`; - layer.append(up, down); - - // 구간 양 끝 중 하나라도 편집됐으면 원복 ↺ 노출(측점 ↺와 동일 스타일·연산). - if (edited.has(chainageKey(segment.from_m)) || edited.has(chainageKey(segment.to_m))) { - const reset = overlayButton( - repeater, - "is-segment is-reset", - "↺", - `${label} — 자동 선형으로 원복`, - () => onResetSegment(segment), - ); - reset.style.left = `${center - BUTTON_HALF_PX}px`; - layer.append(reset); - } - }); + // 구간 평행이동(⬆⬇)과 구간 원복 ↺ 도 같은 지시로 삭제했다. 구간 단위 조작은 + // [직선화]로 만든 직선을 [쉬프트]로 옮기는 흐름이 대신한다. return layer; } diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts new file mode 100644 index 00000000..b1b41cd0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -0,0 +1,138 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_History.ts + * B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷. + * + * 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이 + * 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측 + * 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가 + * 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는 + * **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다. + * + * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 + * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). + * ========================================================================== */ + +/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ +const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; + +/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ +function isLayoutKey(key: string): boolean { + return ( + key.includes("collapsed") || + key.includes("width") || + key.includes("height") || + key.includes("open") || + key.includes("visible") + ); +} + +function isDataKey(key: string): boolean { + return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); +} + +/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ +export type HistorySnapshot = Record; + +function takeSnapshot(): HistorySnapshot { + const snapshot: HistorySnapshot = {}; + try { + for (let index = 0; index < sessionStorage.length; index += 1) { + const key = sessionStorage.key(index); + if (!key || !isDataKey(key)) continue; + const value = sessionStorage.getItem(key); + if (value !== null) snapshot[key] = value; + } + } catch { + // 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다. + } + return snapshot; +} + +function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => left[key] === right[key]); +} + +/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ +function restoreSnapshot(snapshot: HistorySnapshot): void { + try { + const existing: string[] = []; + for (let index = 0; index < sessionStorage.length; index += 1) { + const key = sessionStorage.key(index); + if (key && isDataKey(key)) existing.push(key); + } + existing.forEach((key) => { + if (!(key in snapshot)) sessionStorage.removeItem(key); + }); + Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value)); + } catch { + // 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다. + } +} + +export interface ProfileHistory { + /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ + record(): void; + /** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */ + pause(): void; + /** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */ + resume(): void; + undo(): boolean; + redo(): boolean; + canUndo(): boolean; + canRedo(): boolean; +} + +/** + * 되돌리기 이력을 만든다. + * + * `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로 + * 여기서 각 패널이 자기 값을 다시 읽어 그린다. + */ +export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory { + const stack: HistorySnapshot[] = [takeSnapshot()]; + let cursor = 0; + /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ + let restoring = false; + /** 연속 조작 중에는 record()를 흘려보내고 resume()에서 한 번만 쌓는다. */ + let paused = false; + + function apply(index: number): boolean { + if (index < 0 || index >= stack.length) return false; + cursor = index; + restoring = true; + restoreSnapshot(stack[cursor]); + try { + onRestore(); + } finally { + restoring = false; + } + return true; + } + + return { + record() { + if (restoring || paused) return; + const snapshot = takeSnapshot(); + if (sameSnapshot(snapshot, stack[cursor])) return; + stack.splice(cursor + 1); + stack.push(snapshot); + if (stack.length > limit) stack.shift(); + cursor = stack.length - 1; + }, + pause() { + paused = true; + }, + resume() { + if (!paused) return; + paused = false; + this.record(); + }, + undo: () => apply(cursor - 1), + redo: () => apply(cursor + 1), + canUndo: () => cursor > 0, + canRedo: () => cursor < stack.length - 1, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index 8a2a50b0..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; @@ -113,14 +114,18 @@ export function computeProfileLayout( data: LongitudinalSection, stationIntervalM: number, availableWidth: number, + /** 가로 줌 배수(1 = 현행). 측점 간격 기본값과 폭맞춤 폭에 함께 곱해 넷(그래프·테이블· + * 편집 버튼층·구조물 레인)이 같은 비율로 늘어나게 한다(2026-09-04 사용자 지시). */ + zoomX = 1, ): ProfileLayout { const maxChainage = maxChainageOf(data); const interval = Math.max(stationIntervalM, 1e-6); const framePad = LONG_PAD.left + LONG_PAD.right; - const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER; + const zoom = Math.max(zoomX, 1e-6); + const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER * zoom; // 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함). const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing; - const width = Math.max(minWidth, availableWidth); + const width = Math.max(minWidth, availableWidth * zoom); const pxPerMeter = (width - framePad) / (maxChainage + interval); const spacing = interval * pxPerMeter; return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) }; diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 456ab36d..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,28 +41,33 @@ 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, } from "@util/common_util_mass_haul_view"; +import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; 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와 같아야 한다. */ @@ -70,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 { @@ -101,6 +110,9 @@ export function buildStickyYAxis( const tick = document.createElement("span"); tick.className = "b05-profile__yaxis-tick"; tick.style.top = `${y}px`; + // 종단 그래프의 세로 창 변환이 이 눈금도 같이 옮긴다 — 그릴 때의 y를 남겨 둔다 + // (`_UI_Profile_YWindow`, 2026-09-04). 유토곡선 축에는 갱신이 오지 않아 무해하다. + tick.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); tick.textContent = label; inner.append(tick); }); @@ -124,6 +136,12 @@ export interface RouteMassHaulDrawParams { onSelectStation: (stationId: string) => void; /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ onClearSelection?: () => void; + /** + * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 + * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: + * 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다. + */ + pendingRecalc?: boolean; } export interface RouteMassHaulPanel { @@ -149,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 읽기와 같은 규칙). @@ -164,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 사용자 // 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는 // 툴팁으로 밝힌다. @@ -174,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"); @@ -187,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; @@ -210,7 +241,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa }); }, }); - overlay.append(resizer.root, bar, scroll, legendLayer); + // 재계산 대기 표시 — 곡선 영역 우측 상단에 **떠 있는** 알림이다. 예전에는 요약 막대에 + // 붙였는데 막대가 줄바꿈되며 곡선을 아래로 밀어냈다(2026-09-04 사용자 보고: 「창 밀림」). + const pendingChip = document.createElement("span"); + pendingChip.className = "b05-profile__masshaul-pending"; + pendingChip.textContent = "다시 계산 중…"; + pendingChip.hidden = true; + overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). let clearSelection: (() => void) | undefined; @@ -221,20 +258,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. - scroll.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const limit = scroll.scrollWidth - scroll.clientWidth; - if (limit <= 0) return; - const delta = event.deltaY; - if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) - return; - scroll.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(scroll); /** * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 @@ -247,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 ? "유토곡선 접기" : "유토곡선 펼치기"; @@ -265,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(); } @@ -287,7 +311,12 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa mirror(scroll, main); } + /** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */ + let drawnOnce = false; + function note(message: string): void { + drawnOnce = false; + pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); legendLayer.replaceChildren(); @@ -297,22 +326,81 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa bar.append(empty); } + // 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04). + const windowState = createMassHaulWindowState(); + /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ + 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; } + // 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 옛 계획고로 만든 값이다 — + // 새 값이 올 때까지 기다린다(2026-09-03 사용자 확정: 「새 값만 보여주기」). + // + // 다만 **곡선을 지우지는 않는다**. 계획고를 연속으로 누르면(초당 10회) 매번 곡선이 + // 사라졌다 다시 서서 화면이 깜빡였다(2026-09-03 사용자 보고). 재계산은 브라우저 + // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 + // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. + if (params.pendingRecalc) { + if (drawnOnce) pendingChip.hidden = false; + else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); + return; + } + pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. + // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 + // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, params.crossSections, context.conversion, context.naturalSpoilMinSlope, ); + // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. + emitSummary(series); if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; @@ -322,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 사용자 보고). @@ -338,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, + chartAxis, params.selectedStationId, params.stationInterval, - params.widthPx, + widthPx, chartHeight, - params.widthPx, + widthPx, params.onSelectStation, haulPlan, (axis) => { @@ -358,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, () => { @@ -373,6 +482,11 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa onChanged(); }), ); + drawnOnce = true; + // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. + scheduleMassHaulSettle(windowState, () => { + if (lastParams) draw(lastParams); + }); } return { @@ -386,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_MinCover.ts b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts index c76ed544..e52f83b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts @@ -19,6 +19,10 @@ * 경고하기 위한 같은 산식의 화면 사본이며, 상수 일치는 테스트로 잠가 둔다. * ========================================================================== */ +import { showToast } from "@ui/ui_template_elements"; +import { controlElevationAt } from "./B05_Profile_UI_Profile_Alignment"; +import type { AlignmentBase, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; /** 최소고 판정에 필요한 것만 받는다 — 관 목록의 출처(정본/화면)에 매이지 않는다. */ @@ -127,3 +131,58 @@ export function minCoverWarningText(violations: MinCoverViolation[]): string | n const more = violations.length > 1 ? ` 외 ${violations.length - 1}곳` : ""; return `${worst.label} 최소고 ${worst.shortfall_m.toFixed(2)}m 부족${more}`; } + +/** 종단 격자에서 그 자리 원지반고 — 가드 판정용 선형 보간. */ +function groundAt(base: AlignmentBase, chainageM: number): number | null { + const { chainage: xs, ground: ys } = base; + if (!xs.length) return null; + if (chainageM <= xs[0]) return ys[0]; + if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1]; + for (let i = 1; i < xs.length; i += 1) { + if (chainageM > xs[i]) continue; + const span = xs[i] - xs[i - 1]; + if (span <= 0) return ys[i]; + return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span); + } + return ys[ys.length - 1]; +} + +/** + * 편집 후보가 최소 계획고를 깨면 막는다(2026-08-23 개편). + * + * 위반이 **새로 생기거나 커질 때만** 막는다 — 이미 위반이면 악화만 막아 복구(올림) + * 편집은 언제나 통과한다. 판정점은 제어점 z(라운드 중심)다: 곡선 샘플로 재면 이웃 + * 틸팅이 라운드 형상만 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨"). + * + * 호출 자리는 `_Profile_Panel.applyEdits` **한 곳**이다. 측점 끌기에만 걸어 두었더니 + * [직선화]·[쉬프트]·틸팅·방향키가 그냥 지나갔다(2026-09-02 laptop-main 실측). + * 최소고 강제가 꺼져 있으면(기본 해제, 2026-09-01 사용자 지시) 아무것도 막지 않는다. + */ +export function blocksMinCover( + base: AlignmentBase | null, + alignment: ProfileAlignment | null, + candidate: ProfileAlignment, + enforced: boolean, + targets: MinCoverPoint[], +): boolean { + if (!base || !alignment || !enforced || !targets.length) return false; + const ground = (chainageM: number) => groundAt(base, chainageM); + const planned = findMinCoverViolations(targets, ground, (chainageM) => + controlElevationAt(candidate, chainageM), + ); + if (!planned.length) return false; + const current = new Map( + findMinCoverViolations(targets, ground, (chainageM) => + controlElevationAt(alignment, chainageM), + ).map((violation) => [violation.chainage_m, violation.shortfall_m]), + ); + const worsened = planned.find( + (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, + ); + if (!worsened) return false; + showToast( + `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, + "warning", + ); + return true; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index a9c1e43d..08236aa3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -10,27 +10,26 @@ * `saveProfileAlignment()`로 편집 델타만 보낸다. * ========================================================================== */ +import { routeSpansFromStructures } from "./B05_Profile_UI_Drainage_Spans"; +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 { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - structureAnchorM, - type StructureInstance, - type StructureType, -} from "./B05_Profile_Api_Structures"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { createProfileTableOverlay, TABLE_OVERLAY_MIN_HEIGHT, } from "./B05_Profile_UI_Profile_TableOverlay"; -import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data"; +import { hasLegacyAlignment, readAlignment, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; -import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch"; -import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; +import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common"; import { + blocksMinCover, findMinCoverViolations, minCoverPoints, type MinCoverPoint, @@ -51,6 +50,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"; @@ -58,15 +58,21 @@ import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance"; import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights"; import { renderProfile } from "./B05_Profile_UI_Profile_Render"; import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview"; +import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools"; +import { createProfileZoom } from "./B05_Profile_UI_Profile_Zoom"; +import { + stationIdAtStructure as stationIdAtStructureOf, + structureIdAtStation as structureIdAtStationOf, +} from "./B05_Profile_UI_Profile_Structures"; import "../B06_Section/B06_Section_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. 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; @@ -78,9 +84,16 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13; const MIN_PANEL_HEIGHT = 180; /** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */ const MAX_PANEL_HEIGHT_RATIO = 0.9; -/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms). - * ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */ -const CROSS_PREVIEW_DEBOUNCE_MS = 250; +/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms). + * ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다. + * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — + * 실측 전 측점(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; /** * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. @@ -90,47 +103,12 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 250; * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. */ -/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ -export interface RouteProfilePanelCallbacks { - /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. - * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ - onPipesChanged?: ( - pipes: Array<{ - chainage_m: number; - effective_diameter_mm: number | null; - /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ - design_flow_m3s?: number | null; - facility: PipeFacility; - start_m?: number; - end_m?: number; - source?: PipeSource; - options?: Record; - }>, - ) => void; - /** 테이블에서 구조물 라인을 끌어 옮김. */ - onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; - /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ - onStructureRemove?: (station: IrregularStation) => void; - /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ - onPipeAdd?: (chainageM: number) => void; - /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ - onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; - /** 구조물 라인을 눌러 고름. */ - onIrregularSelect?: (station: IrregularStation) => void; - /** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */ - onStructureSelect?: (structureId: string | null) => void; - /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ - onStructureMarkMove?: (structureId: string, toChainageM: number) => void; - /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ - onStructureTypeAdd?: (chainageM: number, typeId: string) => void; - /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ - onBasinSelected?: (chainageM: number | null) => void; - /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ - onPipeSelected?: (chainageM: number | null) => void; - /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — - * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ - onCrossDesignsUpdated?: () => void; -} +/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로 + * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ +import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; +import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; +import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow"; +export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( projectId: string, @@ -172,7 +150,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); @@ -300,11 +300,10 @@ export function createRouteProfilePanel( let base: AlignmentBase | null = null; let alignment: ProfileAlignment | null = null; let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); + /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ + let savedEdits: AlignmentEdits = emptyEdits(); let resizeTimer = 0; let redrawPending = false; - /** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */ - let crossPreviewTimer = 0; - let crossPreviewSeq = 0; let lastWidth = 0; let lastHeight = 0; @@ -312,33 +311,11 @@ export function createRouteProfilePanel( * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다. */ - /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ - const SAME_CHAINAGE_M = 0.51; - - /** 측점선 id → 알약(구조물) id. 같은 누가거리의 구조물이 없으면 null. */ - function structureIdAtStation(stationId: string | null): string | null { - if (stationId === null) return null; - const prefix = irregularStationId(""); - if (!stationId.startsWith(prefix)) return null; - const station = irregularStations.find((entry) => irregularStationId(entry.id) === stationId); - if (!station) return null; - const hit = structures.find( - (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, - ); - return hit?.structure_id ?? null; - } - - /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ - function stationIdAtStructure(structureId: string | null): string | null { - if (structureId === null) return null; - const structure = structures.find((item) => item.structure_id === structureId); - if (!structure) return null; - const anchor = structureAnchorM(structure); - const station = irregularStations.find( - (entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M, - ); - return station ? irregularStationId(station.id) : null; - } + /** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */ + const structureIdAtStation = (stationId: string | null): string | null => + structureIdAtStationOf(stationId, irregularStations, structures); + const stationIdAtStructure = (structureId: string | null): string | null => + stationIdAtStructureOf(structureId, irregularStations, structures); /** * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). @@ -368,6 +345,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, @@ -375,8 +380,11 @@ export function createRouteProfilePanel( (chainageM) => sampleAt(chainageM, "elevation_m"), ); renderBalanceBar({ + planCurve: planCurveWarning(), minCoverViolations, balanceBar, + tools: tools.render(), + trailing: profileZoom.bar, alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), edited: store.edited(), @@ -389,6 +397,90 @@ export function createRouteProfilePanel( }); } + /* 줌 조작구(가로 배율) — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. + 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ + const profileZoom = createProfileZoom(() => draw()); + + /** + * 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04). + * + * 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`, + * 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든 + * 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이 + * 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + * + * 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다. + * 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다. + */ + function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] { + const master = alignment?.stations; + if (!master?.length) return stations; + return stations.map((station) => { + let best: number | null = null; + let gap = STATION_SNAP_TOLERANCE_M; + for (const node of master) { + const distance = Math.abs(node.chainage_m - station.chainage_m); + if (distance <= gap) { + gap = distance; + best = node.chainage_m; + } + } + return best === null || best === station.chainage_m + ? station + : { ...station, chainage_m: best }; + }); + } + + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ + let elevationWindow: { min: number; max: number } | undefined; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ + let updateElevationWindow: (() => ElevationWindowResult | null) | null = null; + /** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */ + let heightEditing = false; + const holdElevationRange = ( + next: { min: number; max: number } | null, + ): { min: number; max: number } | undefined => { + if (heightEditing) return elevationWindow; + elevationWindow = next ?? undefined; + return elevationWindow; + }; + // 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다. + body.addEventListener("pointerdown", (event) => { + if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; + heightEditing = true; + const release = (): void => { + heightEditing = false; + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + draw(); + }; + window.addEventListener("pointerup", release); + window.addEventListener("pointercancel", release); + }); + + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ + const { tools, history, handleToolPick } = createPanelTools({ + root, + base: () => base, + alignment: () => alignment, + edits: () => store.edits(), + applyEdits: (next) => applyEdits(next), + selectedStationId: () => selectedStationId, + irregularStations: () => irregularStations, + stationIdOf: (station) => irregularStationId(station.id), + moveStation: (station, toChainageM) => + callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), + drainage: drainagePanel, + restore: () => { + // 세션이 정본이므로 편집 초안을 다시 읽어 그린다. + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + rebuild(); + }, + restoreSaved: () => store.restoreSaved(), + canRestoreSaved: () => store.canRestoreSaved(), + refresh: () => renderBalance(), + }); + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ function applyEdits(next: AlignmentEdits): void { if (!base || !alignment) return; @@ -403,7 +495,10 @@ export function createRouteProfilePanel( ); return; } + // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; store.replace(next); + history.record(); } /** @@ -424,48 +519,19 @@ export function createRouteProfilePanel( }); } - /** - * 횡단 설계 프리뷰 요청 — 끌기 중에는 계속 호출되므로 마지막 값만 보낸다. - * 응답이 늦게 와도 그 사이 편집이 더 있었으면 버린다(seq 비교). - */ - function scheduleCrossPreview(): void { - if (!detail || routeId === null) return; - window.clearTimeout(crossPreviewTimer); - crossPreviewTimer = window.setTimeout(() => { - if (!detail || routeId === null) return; - const seq = (crossPreviewSeq += 1); - const targetRouteId = routeId; - // full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한 - // 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값 없으면 - // DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다. - void previewCrossDesigns(projectId, targetRouteId, store.edits(), undefined, { - fullDesigns: true, - }) - .then((next) => { - if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return; - // 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로 - // 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 지반선 샘플은 건드리지 않는다. - const designByChainage = new Map( - next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of detail.cross_sections) { - const full = designByChainage.get(section.chainage_m.toFixed(3)); - if (!full || !section.design) continue; - // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존. - section.design = { - ...(full as NonNullable), - inlet_structure: section.design.inlet_structure, - basin_adjust: section.design.basin_adjust, - }; - } - draw(); - callbacks?.onCrossDesignsUpdated?.(); - }) - .catch(() => { - /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ - }); - }, CROSS_PREVIEW_DEBOUNCE_MS); - } + /** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */ + const crossPreview = createCrossPreview({ + projectId, + detail: () => detail, + routeId: () => routeId, + edits: () => store.edits(), + debounceMs: CROSS_PREVIEW_DEBOUNCE_MS, + onApplied: () => { + draw(); + callbacks?.onCrossDesignsUpdated?.(); + }, + }); + const scheduleCrossPreview = (): void => crossPreview.schedule(); /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 @@ -504,7 +570,6 @@ export function createRouteProfilePanel( stationInterval: () => stationInterval, irregularStations: () => irregularStations, minCoverTargets: () => minCoverTargets, - enforceMinCover: () => enforceMinCover, structures: () => structures, structureTypes: () => structureTypes, selectedStationId: () => selectedStationId, @@ -527,26 +592,42 @@ export function createRouteProfilePanel( }, selectStation, applyEdits, + handleToolPick, + zoom: profileZoom.state, + holdElevationRange, + toolActive: () => tools.mode() !== "none", + selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, redraw: draw, + setElevationWindowUpdater: (update) => { + updateElevationWindow = update; + }, }); } + /** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ + const SCROLL_SETTLE_MS = 80; + /** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */ + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의 + // 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만 + // 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다. + body.addEventListener("scroll", () => { + if (heightEditing) return; + const fitted = updateElevationWindow?.() ?? null; + window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; + scrollSettleTimer = window.setTimeout(() => { + if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = body.scrollLeft; + draw(); + }, SCROLL_SETTLE_MS); + }); + // 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도 // 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다). - body.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const delta = event.deltaY; - const limit = body.scrollWidth - body.clientWidth; - if (limit <= 0) return; - if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return; - body.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(body); const resizeObserver = new ResizeObserver(() => { if ( @@ -566,14 +647,19 @@ 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"); + + /** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */ + function syncDrainageSpans(): void { + drainagePanel.setIntervalSpans(routeSpansFromStructures(structures, structureTypes)); + } return { root, @@ -588,7 +674,8 @@ export function createRouteProfilePanel( // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. if (routeChanged || !store.dirty()) { routeId = nextRouteId ?? routeId; - store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild()); + savedEdits = stored?.edits ?? emptyEdits(); + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); } base = stored ? toAlignmentBase(stored) : null; // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). @@ -605,7 +692,23 @@ export function createRouteProfilePanel( // 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 — // 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D // 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인). - if (staleDesignChainages(nextDetail).length) scheduleCrossPreview(); + // + // 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이 + // 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기 + // 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다 + // (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현). + if ( + hasStaleDesigns({ + longitudinal: { + design_profiles: alignment + ? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])] + : nextDetail.longitudinal.design_profiles, + }, + cross_sections: nextDetail.cross_sections, + }) + ) { + scheduleCrossPreview(); + } }, /** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23). * 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */ @@ -644,12 +747,13 @@ export function createRouteProfilePanel( }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { - irregularStations = stations; + irregularStations = snapToMasterStations(stations); draw(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ setStructureTypes(types: StructureType[]) { structureTypes = types; + syncDrainageSpans(); draw(); }, /** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */ @@ -658,6 +762,7 @@ export function createRouteProfilePanel( if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) { selectedStructureId = null; } + syncDrainageSpans(); draw(); }, /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ @@ -712,7 +817,7 @@ export function createRouteProfilePanel( }, dispose() { window.clearTimeout(resizeTimer); - window.clearTimeout(crossPreviewTimer); + crossPreview.dispose(); resizeObserver.disconnect(); heightResizer.dispose(); window.removeEventListener("pointerup", clearDragFlags); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts new file mode 100644 index 00000000..8bdb8b2a --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -0,0 +1,311 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Panel_Tools.ts + * 종단 패널의 [직선화]·[쉬프트]·되돌리기·방향키 배선 (2026-09-02 사용자 지시). + * + * 패널 본체(`B05_Profile_UI_Profile_Panel`)가 700줄 한계라 조작 배선만 떼어 놓는다. + * 여기서는 상태를 갖지 않고 **본체가 넘긴 접근자**로만 현재 값을 읽는다 — 계획선·편집 + * 델타의 주인은 그대로 본체다. + * + * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. + * ========================================================================== */ + +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "./B05_Profile_UI_Profile_Alignment"; +import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; +import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; +import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; +import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; +import { + detectStraightRun, + hasStraightRuns, + shiftStraightRuns, + straightenBetween, + tiltStraightRun, +} from "./B05_Profile_UI_Profile_Straighten"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; + +/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ +const KEY_STEP_M = 0.1; + +/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ +const PIPE_KEY = "b05:pipes"; + +export interface PanelToolsContext { + /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ + root: HTMLElement; + base: () => AlignmentBase | null; + alignment: () => ProfileAlignment | null; + edits: () => AlignmentEdits; + applyEdits: (next: AlignmentEdits) => void; + /** 고른 측점 id(없으면 null). */ + selectedStationId: () => string | null; + /** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */ + irregularStations: () => IrregularStation[]; + /** 측점 id 만들기 — 목록 id와 그래프 id를 맞춘다. */ + stationIdOf: (station: IrregularStation) => string; + /** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */ + moveStation: (station: IrregularStation, toChainageM: number) => void; + /** 관 목록 정본 — 되돌리기가 구조물 위치까지 되돌리려면 이력이 이 값을 봐야 한다. */ + drainage: { + pipeChainages: () => number[]; + setPipeChainages: (chainages: number[]) => void; + }; + /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ + restore: () => void; + /** 마지막 [저장] 시점으로 편집을 되돌린다(스토어 소관). */ + restoreSaved: () => void; + /** 되돌릴 것이 남아 있는가 — [초기화] 버튼 활성 조건. */ + canRestoreSaved: () => boolean; + /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ + refresh: () => void; +} + +export interface PanelTools { + tools: ProfileTools; + history: ProfileHistory; + /** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */ + handleToolPick: (chainageM: number | null) => boolean; +} + +export function createPanelTools(ctx: PanelToolsContext): PanelTools { + /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ + let initialPipes: number[] | null = null; + + /** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */ + function syncPipes(): void { + const chainages = ctx.drainage.pipeChainages(); + if (initialPipes === null) initialPipes = chainages; + sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages)); + } + + /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */ + function restorePipes(): void { + let chainages = initialPipes; + const raw = sessionStorage.getItem(PIPE_KEY); + if (raw) { + try { + chainages = JSON.parse(raw) as number[]; + } catch { + // 손상된 값은 최초 목록으로 되돌린다. + } + } + if (chainages) ctx.drainage.setPipeChainages(chainages); + } + + // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). + const inner = createProfileHistory(() => { + ctx.restore(); + restorePipes(); + }); + /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. + * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** + * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ + const history: ProfileHistory = { + ...inner, + record: () => (syncPipes(), inner.record()), + pause: () => (syncPipes(), inner.pause()), + }; + + const tools = createProfileTools({ + onStraighten: (fromM, toM) => { + const base = ctx.base(); + if (!base) return; + ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM)); + tools.clear(); + }, + onShift: (runs, delta) => { + const base = ctx.base(); + if (!base) return; + ctx.applyEdits(shiftStraightRuns(base, ctx.edits(), runs, delta)); + }, + onTilt: (run, delta) => { + const base = ctx.base(); + if (!base) return; + ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); + }, + onResetToSaved: () => { + // 이력에 남겨 [↶]로 되살릴 수 있게 한다 — 잘못 눌러도 잃는 것이 없다. + ctx.restoreSaved(); + history.record(); + }, + canResetToSaved: ctx.canRestoreSaved, + onUndo: () => inner.undo(), + onRedo: () => inner.redo(), + canUndo: () => inner.canUndo(), + canRedo: () => inner.canRedo(), + hasStraightRuns: () => { + const alignment = ctx.alignment(); + return alignment ? hasStraightRuns(alignment) : false; + }, + onChanged: ctx.refresh, + }); + + /** 그래프에서 누른 자리를 도구가 먹는다. */ + function handleToolPick(chainageM: number | null): boolean { + const mode = tools.mode(); + if (mode === "none") return false; + if (chainageM === null) return tools.handleRunPick(null); + const alignment = ctx.alignment(); + if (mode === "shift") { + return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null); + } + // 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라 + // 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다. + if (tools.pendingStation() === null && alignment) { + const run = detectStraightRun(alignment, chainageM); + if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) { + return tools.handleRunPick(run); + } + } + return tools.handleStationPick(chainageM); + } + + function selectedIrregular(): IrregularStation | null { + const stationId = ctx.selectedStationId(); + if (stationId === null) return null; + return ctx.irregularStations().find((entry) => ctx.stationIdOf(entry) === stationId) ?? null; + } + + /** 고른 측점의 누가거리 — 비정규 측점이면 목록에서, 규칙 측점이면 선형에서 찾는다. */ + function selectedChainage(): number | null { + const irregular = selectedIrregular(); + if (irregular) return irregular.chainage_m; + const stationId = ctx.selectedStationId(); + const row = ctx.alignment()?.stations.find((station) => station.station_id === stationId); + return row ? row.chainage_m : null; + } + + /* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리. + * 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면 + * 측점번호가 어긋난다(2026-09-02 사용자 확정). + * 누르고 있으면 편집 버튼과 **같은 속도**로 이어진다(0.5초 뒤 초당 10회) — OS 키 반복에 + * 맡기면 기기마다 속도가 갈린다. 그 동안의 변화는 이력 한 덩어리다(2026-09-04 사용자 지시). */ + type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + + /** 방향키 한 번의 조작 — 반복 타이머가 같은 함수를 다시 부른다. 대상이 없으면 false. */ + function nudge(key: ArrowKey): boolean { + const base = ctx.base(); + if (!base) return false; + if (key === "ArrowUp" || key === "ArrowDown") { + const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; + // [쉬프트]가 켜져 있으면 고른 직선 구간의 평행이동 — 도구 ▲▼와 같은 경로다. + if (tools.mode() === "shift") return tools.nudge(delta); + const chainage = selectedChainage(); + if (chainage === null) return false; + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + return true; + } + // 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져 + // (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측). + const station = holdStation ?? selectedIrregular(); + if (!station) return false; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 목록(정본)은 서버 재계산 뒤에야 새 누가거리를 들고 온다 — 연속 이동 중에는 그것을 + // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 + // 0.1m 만 움직이고 멈춤(2026-09-04 실측). + const fromM = holdChainageM ?? station.chainage_m; + const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); + if (next < 0) return false; + ctx.moveStation({ ...station, chainage_m: fromM }, next); + holdStation = station; + holdChainageM = next; + history.record(); + return true; + } + + let heldKey: ArrowKey | null = null; + /** 좌우 연속 이동 중의 현재 누가거리 — 정본 목록이 따라오기 전까지 여기서 센다. */ + let holdChainageM: number | null = null; + /** 옮기는 중인 구조물 측점 — 선택이 풀려도 키를 뗄 때까지 이 측점을 움직인다. */ + let holdStation: IrregularStation | null = null; + let holdDelayTimer = 0; + let holdRepeatTimer = 0; + + /** 구조물 이동은 서버 재계산을 거쳐 돌아오고 그 뒷정리(유령 변화점 삭제)도 이력을 + * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ + const HOLD_SETTLE_MS = 700; + let settleTimer = 0; + + /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ + function stopHold(): void { + window.clearTimeout(holdDelayTimer); + window.clearInterval(holdRepeatTimer); + holdDelayTimer = 0; + holdRepeatTimer = 0; + if (heldKey === null) return; + heldKey = null; + holdChainageM = null; + holdStation = null; + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", stopHold); + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + settleTimer = 0; + history.resume(); + }, HOLD_SETTLE_MS); + } + + /** 기다리지 않고 지금 바로 한 덩어리를 닫는다(다음 조작·되돌리기 직전). */ + function flushHold(): void { + stopHold(); + if (!settleTimer) return; + window.clearTimeout(settleTimer); + settleTimer = 0; + history.resume(); + } + + function onKeyUp(event: KeyboardEvent): void { + if (event.key === heldKey) stopHold(); + } + + /** 첫 한 번은 부른 쪽에서 이미 움직였다 — 여기서는 이어지는 반복만 건다. */ + function startHold(key: ArrowKey): void { + heldKey = key; + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", stopHold); + holdDelayTimer = window.setTimeout(() => { + holdRepeatTimer = window.setInterval(() => { + if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. + }, HOLD_INTERVAL_MS); + }, HOLD_DELAY_MS); + } + + /* 듣는 자리는 **창(window)** 이다 — 패널에 걸어 두면 그래프를 눌러도 포커스가 body에 + * 남아(측점을 고르면 그래프가 다시 그려져 포커스가 풀림) 방향키가 아무 반응이 없었다 + * (2026-09-04 실측). 패널이 화면에서 빠지면(다른 페이지) 조용히 무시한다. */ + window.addEventListener("keydown", (event) => { + if (!ctx.root.isConnected) return; + const target = event.target as HTMLElement | null; + // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. + if (target && target.closest("input, select, textarea")) return; + const key = event.key; + if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { + event.preventDefault(); + flushHold(); + if (event.shiftKey) history.redo(); + else history.undo(); + return; + } + if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { + return; + } + // OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다. + if (event.repeat) { + event.preventDefault(); + return; + } + // 기록을 먼저 멈춘다 — 첫 한 번까지 같은 덩어리에 들어가야 되돌리기 1회로 원위치한다. + flushHold(); + history.pause(); + if (!nudge(key)) { + history.resume(); // 움직인 것이 없으므로 이력에 남지 않는다. + return; + } + event.preventDefault(); + startHold(key); + }); + + return { tools, history, handleToolPick }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts new file mode 100644 index 00000000..78c08118 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -0,0 +1,52 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Panel_Types.ts + * 종단 패널이 Page 로 올려 보내는 알림(콜백) 타입. + * + * `B05_Profile_UI_Profile_Panel` 이 700줄을 넘겨 **타입만** 떼어낸 조각이다(2026-09-04). + * 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다. + * ========================================================================== */ + +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; + +/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ +export interface RouteProfilePanelCallbacks { + /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. + * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ + onPipesChanged?: ( + pipes: Array<{ + chainage_m: number; + effective_diameter_mm: number | null; + /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ + design_flow_m3s?: number | null; + facility: PipeFacility; + start_m?: number; + end_m?: number; + source?: PipeSource; + options?: Record; + }>, + ) => void; + /** 테이블에서 구조물 라인을 끌어 옮김. */ + onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; + /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ + onStructureRemove?: (station: IrregularStation) => void; + /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ + onPipeAdd?: (chainageM: number) => void; + /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ + onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; + /** 구조물 라인을 눌러 고름. */ + onIrregularSelect?: (station: IrregularStation) => void; + /** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */ + onStructureSelect?: (structureId: string | null) => void; + /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ + onStructureMarkMove?: (structureId: string, toChainageM: number) => void; + /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ + onStructureTypeAdd?: (chainageM: number, typeId: string) => void; + /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ + onBasinSelected?: (chainageM: number | null) => void; + /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ + onPipeSelected?: (chainageM: number | null) => void; + /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — + * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ + onCrossDesignsUpdated?: () => void; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts new file mode 100644 index 00000000..b6c74daa --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -0,0 +1,70 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Preview.ts + * 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계). + * + * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 서버에 한 번 물어 전 측점을 다시 계산해 + * **공유 캐시가 들고 있는 같은 객체**를 제자리 갱신한다. 그래야 횡단 기준 유토곡선과 + * 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고). + * + * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. + * ========================================================================== */ + +import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; +import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; + +export interface CrossPreviewContext { + projectId: string; + detail: () => SectionDetailResponse | null; + routeId: () => number | null; + edits: () => AlignmentEdits; + debounceMs: number; + /** 반영이 끝난 뒤 다시 그린다. */ + onApplied: () => void; +} + +export interface CrossPreview { + /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ + schedule: () => void; + /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ + dispose: () => void; +} + +export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { + let timer = 0; + let seq = 0; + + return { + schedule() { + if (!ctx.detail() || ctx.routeId() === null) return; + window.clearTimeout(timer); + timer = window.setTimeout(() => { + const detail = ctx.detail(); + const routeId = ctx.routeId(); + if (!detail || routeId === null) return; + const current = (seq += 1); + // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 + // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). + const isCurrent = (): boolean => + current === seq && ctx.detail() === detail && ctx.routeId() === routeId; + void refreshCrossDesigns({ + projectId: ctx.projectId, + routeId, + detail, + edits: ctx.edits(), + // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. + shouldApply: isCurrent, + }) + .then((updated) => { + if (updated.length) ctx.onApplied(); + }) + .catch(() => { + /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ + }); + }, ctx.debounceMs); + }, + dispose() { + window.clearTimeout(timer); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 6f4a2d8d..062d250e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -8,28 +8,36 @@ * 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다. * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; +import { dropStationsNear } from "./B05_Profile_Util_Station"; import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; -import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; +import { + hasStaleDesigns, + LONG_PAD, + windowElevationRange, +} from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { adjustStation, - buildAlignment, - controlElevationAt, setCurveRadius, - shiftMovingPoints, - shiftSegment, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; -import { findMinCoverViolations, type MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; +import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; +import { + applyElevationWindow, + Y_AXIS_WINDOW_CLASS, + type ElevationWindowResult, +} from "@util/common_util_chart_ywindow"; +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; +import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; +import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { CELL_GAP_PX, @@ -66,10 +74,9 @@ export interface ProfileRenderContext { base: () => AlignmentBase | null; stationInterval: () => number | undefined; irregularStations: () => IrregularStation[]; - /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */ + /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 요약줄 경고 표시에 쓴다. + * 편집 차단 가드는 `_Profile_Panel.applyEdits` 한 곳으로 옮겼다(2026-09-02). */ minCoverTargets: () => MinCoverPoint[]; - /** 최소고를 편집에서 강제할지 — 꺼져 있으면 차단하지 않는다(2026-09-01, 기본 해제). */ - enforceMinCover: () => boolean; structures: () => StructureInstance[]; structureTypes: () => StructureType[]; selectedStationId: () => string | null; @@ -85,9 +92,27 @@ export interface ProfileRenderContext { clearMainDragCooldown: () => void; selectStation: (stationId: string | null) => void; applyEdits: (next: AlignmentEdits) => void; + /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ + handleToolPick: (chainageM: number | null) => boolean; + /** 가로 폭 배수(줌 조작구 상태) — 세로는 자동이라 배율이 없다(2026-09-04). */ + zoom: () => ProfileZoomState; + /** + * 세로 자동 맞춤의 Y 창을 넘겨 주고 **실제로 쓸 창**을 돌려받는다. 계획고를 끌어 올리는 + * 동안에는 본체가 직전 창을 붙잡아 돌려준다 — 축이 손 따라 움직이면 조작 감각이 깨진다. + */ + holdElevationRange: ( + next: { min: number; max: number } | null, + ) => { min: number; max: number } | undefined; + /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ + toolActive: () => boolean; + /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ + selectedRuns: () => StraightRun[]; stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; + /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기를 패널에 넘긴다(2026-09-04). + * 가로 스크롤마다 이것만 부르면 그래프 재구성(실측 34ms)이 사라진다. */ + setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void; } /** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */ @@ -112,15 +137,20 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.renderBalance(); const longitudinal = detail.longitudinal; + const zoom = ctx.zoom(); + // 가로 줌은 **폭 배수**다 — 캔버스가 넓어지고 가로 스크롤로 훑는다. 그래프·테이블· + // 편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 폭 하나만 키우면 넷이 함께 늘어난다. const availableWidth = Math.max(1, body.clientWidth - 15); // 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면 // 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다. const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m; const layout = alignment && stationIntervalM - ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth) + ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth, zoom.x) : { - width: Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)), + width: + Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)) * + zoom.x, originOffset: 0, cellWidth: STATION_SPACING_PX - CELL_GAP_PX, }; @@ -171,6 +201,23 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시). // 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation). chartWrap.addEventListener("click", (event) => { + // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 + // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). + if (ctx.toolActive()) { + const marker = (event.target as HTMLElement).closest(".b06-chart__station"); + const raw = marker?.getAttribute("data-chainage"); + if (raw !== null && raw !== undefined) { + if (ctx.handleToolPick(Number(raw))) return; + } else { + const rect = chartWrap.getBoundingClientRect(); + const chainage = chainageInverter( + longitudinal, + width, + layout.originOffset, + )(event.clientX - rect.left + chartWrap.scrollLeft); + if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; + } + } if ((event.target as HTMLElement).closest(".b06-chart__station")) return; if (ctx.selectedStationId() !== null) ctx.selectStation(null); // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 @@ -191,15 +238,33 @@ 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`)와 본문 폭이 곧 보이는 구간이다. + const toChainage = chainageInverter(longitudinal, width, originOffset); + const maxChainageM = maxChainageOf(longitudinal); + const viewFromM = Math.max(0, toChainage(scrollLeft)); + const viewToM = Math.min(maxChainageM, toChainage(scrollLeft + body.clientWidth)); + const elevationRange = ctx.holdElevationRange( + windowElevationRange( + [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], + viewFromM, + viewToM, + ) ?? null, + ); let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; chartWrap.append( createLongitudinalProfile( graphLongitudinal, selectedStationId, + // 세로 배율은 1 고정 — 확대·축소 몫은 아래 `elevationRange`(자동 맞춤)가 맡는다. 1, undefined, ctx.selectStation, @@ -224,6 +289,10 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다 // (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지. 15, + // 창 중심 이동은 쓰지 않는다 — 보이는 구간에 맞춘 Y 창이 이미 가운데다. + 0, + // 보이는 구간의 지반·계획선 범위(위아래 10% 여유는 렌더러가 붙인다). + elevationRange, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. @@ -267,60 +336,46 @@ export function renderProfile(ctx: ProfileRenderContext): void { }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), }); + // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 + // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). + // 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다). + let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null; + ctx.setElevationWindowUpdater(() => { + const from = Math.max(0, toChainage(body.scrollLeft)); + const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); + const next = ctx.holdElevationRange( + windowElevationRange( + [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], + from, + to, + ) ?? null, + ); + massHaulUpdater?.(from, to); + return applyElevationWindow(chartWrap, next); + }); + // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). - if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); + if (yAxis) { + const axisOverlay = buildStickyYAxis(yAxis, chartHeight); + // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). + axisOverlay.classList.add(Y_AXIS_WINDOW_CLASS); + chartWrap.prepend(axisOverlay); + } + // 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다. + if (alignment) { + const highlight = createRunHighlight({ + alignment, + runs: ctx.selectedRuns(), + x, + axis: yAxis, + widthPx: width, + heightPx: chartHeight, + }); + if (highlight) chartWrap.append(highlight); + } if (alignment) { - // 횡단배수 최소 계획고 가드(2026-08-23 개편): 편집 **후보**로 정렬을 미리 계산해 - // 시설별 최소고(배수관 관경+토피 · BOX암거 구체높이+토피 · 세월교 +물넘이 몫, - // 산식은 minCoverPoints 동일 원천) 위반이 **새로 생기거나 커지면** 차단한다. - // 측점 ▼뿐 아니라 구간 ⇧⇩, 이웃 틸트가 종단곡선(중앙종거)을 거쳐 배관 계획고를 - // 내리는 경로까지 같은 가드로 잡는다 — 기존 관경 고정 산식은 구간 쉬프트를 아예 - // 안 막았고 BOX암거·세월교를 과소 차단했다(2026-08-23 사용자 보고). - // 이미 위반이면 악화만 막는다 — 복구 편집(올림)은 항상 허용돼야 한다. - const groundAt = (chainageM: number): number | null => { - if (!base || !base.chainage.length) return null; - const { chainage: xs, ground: ys } = base; - if (chainageM <= xs[0]) return ys[0]; - if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1]; - for (let i = 1; i < xs.length; i += 1) { - if (chainageM > xs[i]) continue; - const span = xs[i] - xs[i - 1]; - if (span <= 0) return ys[i]; - return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span); - } - return ys[ys.length - 1]; - }; - const blocksMinCover = (next: AlignmentEdits): boolean => { - if (!base) return false; - // 최소고 강제가 꺼져 있으면 막지 않는다(2026-09-01 사용자 지시 — 기본 해제). - // 부족분은 상단 표시줄 경고(minCoverWarningText)로 계속 알린다. - if (!ctx.enforceMinCover()) return false; - const targets = ctx.minCoverTargets(); - if (!targets.length) return false; - // 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만 - // 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨"). - const candidate = buildAlignment(base, next); - const planned = findMinCoverViolations(targets, groundAt, (chainageM) => - controlElevationAt(candidate, chainageM), - ); - if (!planned.length) return false; - const current = new Map( - findMinCoverViolations(targets, groundAt, (chainageM) => - controlElevationAt(alignment, chainageM), - ).map((violation) => [violation.chainage_m, violation.shortfall_m]), - ); - const worsened = planned.find( - (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, - ); - if (!worsened) return false; - showToast( - `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, - "warning", - ); - return true; - }; chartWrap.append( createEditOverlay({ alignment, @@ -332,25 +387,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { (entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, ), + // 최소고 가드는 `_Profile_Panel.applyEdits` 한 곳에 있다 — 여기 따로 걸면 + // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; - const next = adjustStation(base, store.edits(), chainage, delta); - if (blocksMinCover(next)) return; - ctx.applyEdits(next); - }, - onSegment: (segment, delta) => { - if (!base) return; - const next = shiftSegment(base, store.edits(), segment, delta); - if (next === store.edits() || blocksMinCover(next)) return; - ctx.applyEdits(next); - }, - // 쉬프트 가능 구간 판정 — 안쪽 미틸트 측점 2개(힌지) 확보 못 하면 버튼 숨김. - canShift: (segment) => shiftMovingPoints(alignment, segment) !== null, - onResetStation: (chainage) => store.resetStation(chainage), - // 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2). - onResetSegment: (segment) => { - store.resetStation(segment.from_m); - store.resetStation(segment.to_m); + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); }, }), ); @@ -368,12 +409,21 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. if (alignment && designProfiles[0]) { - massHaul.draw({ + const massHaulParams: RouteMassHaulDrawParams = { stationSource: graphLongitudinal, // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, crossSections: detail.cross_sections, + // 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이 + // 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). + // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 + // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 + // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). + pendingRecalc: hasStaleDesigns({ + longitudinal: { design_profiles: designProfiles }, + cross_sections: detail.cross_sections, + }), axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 @@ -382,9 +432,12 @@ 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 }, }, stationInterval: stationIntervalM ?? 1, widthPx: width, @@ -393,6 +446,17 @@ export function renderProfile(ctx: ProfileRenderContext): void { onClearSelection: () => { if (ctx.selectedStationId() !== null) ctx.selectStation(null); }, - }); + }; + massHaul.draw(massHaulParams); + // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 + // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 + // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 + // 값보다 크다(요소 수가 종단의 1/3). + massHaulUpdater = (fromM, toM) => { + massHaul.draw({ + ...massHaulParams, + axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, + }); + }; } } diff --git a/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts new file mode 100644 index 00000000..c8db8b66 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_RunHighlight.ts + * [쉬프트]로 고른 직선 구간 강조 오버레이 (2026-09-03 사용자 지시). + * + * 고른 직선을 눌러도 화면에 아무 표시가 없어 무엇이 잡혔는지 알 수 없었다. 강조 범위는 + * **직선 + 양 끝 라운드(R)** 다 — 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의 + * 모양까지 함께 바뀌기 때문이다. 그래서 구간 시작 라운드의 BVC부터 끝 라운드의 EVC까지 + * 계획선 위를 그대로 덧그린다. + * + * 종단 그래프 SVG(`B06_Section_UI_Longitudinal`)는 B05·B06 공용이라 건드리지 않고, + * 같은 좌표계 위에 별도 오버레이를 얹는다. Y 매핑은 그 렌더러가 넘겨 준 축 눈금 + * (`onYAxis`)에서 되짚는다 — 두 눈금의 (표고, y) 두 쌍이면 1차식이 정해진다. + * ========================================================================== */ + +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 두 chainage를 같은 변화점으로 볼 허용 오차(m). */ +const SAME_NODE_M = 1e-6; + +export interface RunHighlightOptions { + alignment: ProfileAlignment; + /** 강조할 구간들(쉬프트 선택분). 비어 있으면 오버레이를 만들지 않는다. */ + runs: StraightRun[]; + /** 누가거리 → 화면 x(px). 종단 그래프와 같은 매핑이어야 선이 겹친다. */ + x: (chainageM: number) => number; + /** 종단 렌더러가 넘겨 준 Y축 눈금 — 표고 → y(px) 를 되짚는 근거. */ + axis: { ticks: Array<{ y: number; label: string }> } | null; + widthPx: number; + heightPx: number; +} + +/** 눈금 라벨(`880m`)에서 표고를 읽는다. 숫자가 아니면 null. */ +function tickElevation(label: string): number | null { + const value = Number.parseFloat(label); + return Number.isFinite(value) ? value : null; +} + +/** + * 축 눈금 두 개로 표고 → y(px) 1차식을 만든다. 눈금이 모자라거나 겹치면 null. + * (렌더러와 같은 스케일을 쓰려는 것이므로 별도로 계산하지 않는다.) + */ +function elevationToY( + axis: { ticks: Array<{ y: number; label: string }> } | null, +): ((elevationM: number) => number) | null { + const points = (axis?.ticks ?? []) + .map((tick) => ({ y: tick.y, elevation: tickElevation(tick.label) })) + .filter((entry): entry is { y: number; elevation: number } => entry.elevation !== null); + if (points.length < 2) return null; + const first = points[0]; + const last = points[points.length - 1]; + const span = last.elevation - first.elevation; + if (Math.abs(span) < 1e-9) return null; + const scale = (last.y - first.y) / span; + return (elevationM: number): number => first.y + (elevationM - first.elevation) * scale; +} + +/** 구간 양 끝 라운드까지 넓힌 강조 범위 [시작, 끝] (누가거리 m). */ +function runSpanWithCurves(alignment: ProfileAlignment, run: StraightRun): [number, number] { + const startCurve = alignment.curves.find( + (curve) => Math.abs(curve.chainage_m - run.fromM) <= SAME_NODE_M && !curve.omitted, + ); + const endCurve = alignment.curves.find( + (curve) => Math.abs(curve.chainage_m - run.toM) <= SAME_NODE_M && !curve.omitted, + ); + return [startCurve ? startCurve.bvc_m : run.fromM, endCurve ? endCurve.evc_m : run.toM]; +} + +/** + * 고른 구간을 계획선 위에 빨갛게 덧그린 오버레이. 고른 것이 없으면 null. + * 반환한 요소는 종단 차트 래퍼(`b05-profile__chart`) 안에 그대로 붙이면 된다. + */ +export function createRunHighlight(options: RunHighlightOptions): SVGElement | null { + const { alignment, runs, x, widthPx, heightPx } = options; + if (!runs.length) return null; + const toY = elevationToY(options.axis); + if (!toY) return null; + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "b05-profile-runmark"); + svg.setAttribute("width", String(widthPx)); + svg.setAttribute("height", String(heightPx)); + svg.setAttribute("viewBox", `0 0 ${widthPx} ${heightPx}`); + + const samples = alignment.samples; + for (const run of runs) { + const [fromM, toM] = runSpanWithCurves(alignment, run); + // 계획선 샘플에는 변화점·BVC·EVC가 모두 들어 있어 라운드 곡률까지 그대로 따라온다. + const points = samples + .filter((sample) => sample.chainage_m >= fromM - SAME_NODE_M) + .filter((sample) => sample.chainage_m <= toM + SAME_NODE_M) + .map((sample) => `${x(sample.chainage_m).toFixed(2)},${toY(sample.elevation_m).toFixed(2)}`); + if (points.length < 2) continue; + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("class", "b05-profile-runmark__line"); + line.setAttribute("points", points.join(" ")); + svg.append(line); + // 양 끝 표시 — 어디까지가 이 구간인지(라운드 포함) 한눈에 보이게 세로 표식을 둔다. + for (const edge of [fromM, toM]) { + const tick = document.createElementNS(SVG_NS, "line"); + tick.setAttribute("class", "b05-profile-runmark__edge"); + tick.setAttribute("x1", x(edge).toFixed(2)); + tick.setAttribute("x2", x(edge).toFixed(2)); + tick.setAttribute("y1", "0"); + tick.setAttribute("y2", String(heightPx)); + svg.append(tick); + } + } + return svg.childElementCount ? svg : null; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts new file mode 100644 index 00000000..5b9279de --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts @@ -0,0 +1,279 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Straighten.ts + * 종단 계획선 [직선화]·[쉬프트] 기하 (2026-09-02 사용자 지시). + * + * 초기 계획선이 **전체 측점 폴리라인**(계획고 = 지반고)으로 바뀌면서, 구간을 다루는 + * 조작은 두 가지가 된다. + * + * [직선화] 측점 2개를 고르면 두 측점의 라운드에 **탄젠트한 직선**으로 잇는다. + * 사이에 있던 라운드는 전부 지운다. 두 끝 측점은 변화점으로 남아 있어 + * 그 자리 라운드가 직선의 접선 역할을 한다. + * [쉬프트] 직선화된 라인을 고르면(복수 가능) 그 폴리라인 전체를 위·아래로 옮긴다. + * 직선 내부는 한 줄이므로 평행이동 = **최외곽 라운드 중심을 같이 옮기는 것**과 + * 같고, 바깥 고정점과는 새 직선으로 다시 이어진다. + * + * 직선 구간은 따로 저장하지 않고 **기하에서 되읽는다**(`detectStraightRun`) — 편집 + * 델타 스키마(`station_offsets`·`curve_radii`)를 그대로 두어 서버 계약을 건드리지 않는다. + * ========================================================================== */ + +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "./B05_Profile_UI_Profile_Alignment"; +import { + buildAlignment, + chainageKey, + controlElevationAt, +} from "./B05_Profile_UI_Profile_Alignment"; + +/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */ +const COLLINEAR_EPSILON = 1e-6; +/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */ +const SAME_STATION_M = 1e-6; + +/** 직선화된 한 구간 — 양 끝 변화점과 그 사이 측점들. */ +export interface StraightRun { + fromM: number; + toM: number; + /** 양 끝을 포함한 구간 안 변화점 chainage (오름차순). */ + nodes: number[]; +} + +/** 현재 선형의 변화점 chainage 목록 (오름차순). */ +function pviChainages(alignment: ProfileAlignment): number[] { + return alignment.pvi.map((node) => node.chainage_m); +} + +/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */ +function baseElevationAt(base: AlignmentBase, chainageM: number): number { + const nodes = base.basePvi; + if (!nodes.length) return 0; + if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m; + const last = nodes[nodes.length - 1]; + if (chainageM >= last.chainage_m) return last.elevation_m; + for (let index = 1; index < nodes.length; index += 1) { + if (nodes[index].chainage_m < chainageM) continue; + const previous = nodes[index - 1]; + const current = nodes[index]; + const span = current.chainage_m - previous.chainage_m; + const ratio = span > 1e-12 ? (chainageM - previous.chainage_m) / span : 0; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + } + return last.elevation_m; +} + +/** 변화점 사이 기울기 (index 번째 구간). */ +function gradeAt(alignment: ProfileAlignment, index: number): number { + const segment = alignment.segments[index]; + if (!segment || segment.length_m <= 1e-12) return 0; + return segment.height_m / segment.length_m; +} + +/** + * 그 자리를 지나는 **최대 직선 구간**을 찾는다. + * + * 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점 + * 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만 + * 두 측점 이상으로 이어진 직선이 된다. + */ +export function detectStraightRun( + alignment: ProfileAlignment, + chainageM: number, +): StraightRun | null { + const nodes = pviChainages(alignment); + if (nodes.length < 2) return null; + // 클릭 지점을 포함하는 구간 index + let index = alignment.segments.findIndex( + (segment) => + chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M, + ); + if (index < 0) return null; + const grade = gradeAt(alignment, index); + let first = index; + let last = index; + while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) { + first -= 1; + } + while ( + last < alignment.segments.length - 1 && + Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON + ) { + last += 1; + } + // 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다. + if (last === first) return null; + const fromM = alignment.segments[first].from_m; + const toM = alignment.segments[last].to_m; + return { + fromM, + toM, + nodes: nodes.filter( + (value) => value >= fromM - SAME_STATION_M && value <= toM + SAME_STATION_M, + ), + }; +} + +/** + * 지금 계획선에 **직선화된 구간이 하나라도 있는가** — [쉬프트] 안내 문구용. + * + * 초기 계획선은 모든 측점이 변화점이라 마디마다 기울기가 달라 직선 구간이 없다. 그때 + * [쉬프트]로 아무 데나 눌러도 잡히는 것이 없어 「선택이 안 된다」로 보였다 + * (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다. + */ +export function hasStraightRuns(alignment: ProfileAlignment): boolean { + for (let index = 1; index < alignment.segments.length; index += 1) { + if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) { + return true; + } + } + return false; +} + +/** 두 측점 사이(끝 제외)의 변화점 chainage. */ +function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] { + return pviChainages(alignment).filter( + (value) => value > fromM + SAME_STATION_M && value < toM - SAME_STATION_M, + ); +} + +/** 편집 델타에 측점 오프셋을 써 넣는다(기존 객체는 건드리지 않는다). */ +function withOffsets( + edits: AlignmentEdits, + updates: Array<[string, number]>, + dropRadiusKeys: string[] = [], +): AlignmentEdits { + const stationOffsets = { ...edits.station_offsets }; + const curveRadii = { ...edits.curve_radii }; + updates.forEach(([key, value]) => { + stationOffsets[key] = Number(value.toFixed(6)); + }); + dropRadiusKeys.forEach((key) => delete curveRadii[key]); + return { station_offsets: stationOffsets, curve_radii: curveRadii }; +} + +/** + * 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다. + * + * 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의 + * 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다). + */ +export function straightenBetween( + base: AlignmentBase, + edits: AlignmentEdits, + fromChainageM: number, + toChainageM: number, +): AlignmentEdits { + const fromM = Math.min(fromChainageM, toChainageM); + const toM = Math.max(fromChainageM, toChainageM); + const span = toM - fromM; + if (span <= SAME_STATION_M) return edits; + const current = buildAlignment(base, edits); + const startZ = controlElevationAt(current, fromM); + const endZ = controlElevationAt(current, toM); + const inner = interiorNodes(current, fromM, toM); + if (!inner.length) return edits; + const updates: Array<[string, number]> = inner.map((chainage) => { + const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span; + return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; + }); + return withOffsets(edits, updates, inner.map(chainageKey)); +} + +/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */ +function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number { + const length = base.policy.default_curve_length_m; + if (typeof length === "number" && Number.isFinite(length) && length > 0) return length; + return base.policy.default_curve_radius_m * Math.abs(deltaGrade); +} + +/** + * 직선화된 구간을 위·아래로 꺾는다 — **가운데 측점에 라운드를 넣고 양쪽에 탄젠트 직선**. + * + * 가운데 측점을 delta만큼 옮기고, 그 좌우를 각각 새 직선 위에 다시 올린다. 라운드는 + * 그 변화점에만 생기며(전체 측점 폴리라인은 R을 지정한 자리에만 라운드를 만든다), + * R은 기본 곡선길이 L에서 R = L / |대수차| 로 역산해 둔다. + */ +export function tiltStraightRun( + base: AlignmentBase, + edits: AlignmentEdits, + run: StraightRun, + delta: number, +): AlignmentEdits { + const current = buildAlignment(base, edits); + const inner = interiorNodes(current, run.fromM, run.toM); + if (!inner.length) return edits; + const center = (run.fromM + run.toM) / 2; + const pivot = inner.reduce((best, chainage) => + Math.abs(chainage - center) < Math.abs(best - center) ? chainage : best, + ); + const startZ = controlElevationAt(current, run.fromM); + const endZ = controlElevationAt(current, run.toM); + const span = run.toM - run.fromM; + const pivotZ = + startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta; + + const updates: Array<[string, number]> = [ + [chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)], + ]; + const place = (chainage: number, aM: number, aZ: number, bM: number, bZ: number): void => { + const width = bM - aM; + if (width <= 1e-12) return; + const target = aZ + ((bZ - aZ) * (chainage - aM)) / width; + updates.push([chainageKey(chainage), target - baseElevationAt(base, chainage)]); + }; + inner.forEach((chainage) => { + if (Math.abs(chainage - pivot) < SAME_STATION_M) return; + if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ); + else place(chainage, pivot, pivotZ, run.toM, endZ); + }); + + const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12); + const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12); + const deltaGrade = Math.abs(gradeOut - gradeIn); + const next = withOffsets( + edits, + updates, + inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey), + ); + if (deltaGrade < 1e-9) return next; + return { + ...next, + curve_radii: { + ...next.curve_radii, + [chainageKey(pivot)]: Number((defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6)), + }, + }; +} + +/** + * 고른 직선 구간들을 통째로 위·아래로 옮긴다. + * + * 직선 내부는 한 줄이므로 구간 안 변화점을 모두 같은 델타로 옮기는 것이 곧 + * **최외곽 라운드 중심을 옮기는 것**이다(사이 점들은 그 사이에 그대로 실려 간다). + * 바깥 고정점(BP·EP·다른 변화점)과는 새 기울기의 직선으로 다시 이어진다. + */ +export function shiftStraightRuns( + base: AlignmentBase, + edits: AlignmentEdits, + runs: StraightRun[], + delta: number, +): AlignmentEdits { + if (!runs.length) return edits; + const current = buildAlignment(base, edits); + const moving = new Set(); + runs.forEach((run) => run.nodes.forEach((chainage) => moving.add(chainage))); + const updates: Array<[string, number]> = [...moving].map((chainage) => { + const target = controlElevationAt(current, chainage) + delta; + return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; + }); + return withOffsets(edits, updates); +} + +/** 같은 구간인지 — 선택 목록에서 중복을 걸러낼 때 쓴다. */ +export function sameRun(left: StraightRun, right: StraightRun): boolean { + return ( + Math.abs(left.fromM - right.fromM) < SAME_STATION_M && + Math.abs(left.toM - right.toM) < SAME_STATION_M + ); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index b23b6a21..c22646ad 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -10,7 +10,12 @@ * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { isPipeStation, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import { + irregularStationId, + isPipeStation, + type IrregularStation, +} from "./B05_Profile_UI_IrregularStations"; +import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel"; /** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */ @@ -117,3 +122,42 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti host.append(menu.element); } + +/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ +export const SAME_CHAINAGE_M = 0.51; + +/** + * 측점선 id ↔ 알약(구조물) id 짝짓기. + * + * 둘은 한 구조물의 두 표시라 선택이 함께 움직여야 한다(2026-08-17 사용자 지시). + * 패널 본체가 700줄 한계라 이 짝짓기만 여기로 옮겼다(2026-09-02). + */ +export function structureIdAtStation( + stationId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], +): string | null { + if (stationId === null) return null; + const prefix = irregularStationId(""); + if (!stationId.startsWith(prefix)) return null; + const station = stations.find((entry) => irregularStationId(entry.id) === stationId); + if (!station) return null; + const hit = structures.find( + (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, + ); + return hit?.structure_id ?? null; +} + +/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ +export function stationIdAtStructure( + structureId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], +): string | null { + if (structureId === null) return null; + const structure = structures.find((item) => item.structure_id === structureId); + if (!structure) return null; + const anchor = structureAnchorM(structure); + const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M); + return station ? irregularStationId(station.id) : null; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 347b6678..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=` 같은 접두를 붙이지 않는다 — 행 이름표가 이미 항목과 단위를 말해준다. @@ -75,6 +76,33 @@ const CELL_PADDING_PX = 2; export const TABLE_TARGET_FONT_PX = 12; /** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */ const CURVE_ROTATED_WIDTH_PX = 16; +/** + * 같은 측점으로 볼 누가거리 차이(m). + * + * 구조물 목록의 누가거리(`85.59`)와 계획선 측점의 누가거리(`85.595`)는 소수 셋째 자리에서 + * 어긋난다 — 정본을 만든 경로가 달라 최대 0.05m 벌어진다(`_Profile_Edit` 의 같은 상수 참조). + * 문자열 키로 맞추면 구조물 열을 못 알아봐 값이 그대로 펼쳐졌다(2026-09-04 실측). + */ +const SAME_STATION_TOLERANCE_M = 0.1; + +/** + * 대수차가 이 값보다 작으면 **직선**으로 본다(%). + * + * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 + * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 + * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. + */ +const STRAIGHT_DELTA_PCT = 1e-4; + +/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ +function isStraightCurve(curve: AlignmentCurve): boolean { + return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; +} + +/** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */ +function isNearChainage(chainageM: number, list: ReadonlyArray): boolean { + return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M); +} /** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */ export function tableCellWidthFor(fontPx: number): number { @@ -146,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) }, ]; @@ -167,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", @@ -187,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: "측점번호+잔여거리", @@ -206,7 +213,7 @@ function buildStationRows( } /** - * 구배 3행. 각 블록은 **변화점에서 변화점까지**를 덮는다. + * 구배 행. 각 블록은 **변화점에서 변화점까지**를 덮는다. * * 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고, * 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의 @@ -218,15 +225,18 @@ function buildSegmentRows( x: (chainage: number) => number, fontPx: number, rowHeight: number, - /** 구조물(비정규) 측점 승격으로 생긴 변화점 chainage 집합(소수 3자리 키). */ - structureChainages: ReadonlySet, + /** 구조물(비정규) 측점 승격으로 생긴 변화점 chainage 목록(m). */ + structureChainages: ReadonlyArray, /** 현재 선택된 측점 chainage — 구조물 파생 블록은 선택됐을 때만 값을 보인다. */ selectedChainageM: number | null, ): HTMLElement[] { // 구조물 배치로 갈라진 구간인지 — 양 끝 중 하나라도 구조물 변화점이면 파생 블록이다. + // 자리 비교는 **허용오차**로 한다(2026-09-04) — 소수 3자리 문자열로 맞추면 같은 + // 자리인데도 끝자리가 갈려(85.590 ↔ 85.594) 구조물 구간으로 안 잡혔다. const isStructureSegment = (segment: AlignmentSegment): boolean => - structureChainages.has(segment.from_m.toFixed(3)) || - structureChainages.has(segment.to_m.toFixed(3)); + structureChainages.some( + (at) => Math.abs(segment.from_m - at) < 0.01 || Math.abs(segment.to_m - at) < 0.01, + ); const touchesSelected = (segment: AlignmentSegment): boolean => selectedChainageM !== null && (Math.abs(segment.from_m - selectedChainageM) < 0.01 || @@ -238,23 +248,13 @@ function buildSegmentRows( const span = x(segment.to_m) - left; const text = spec.cell(segment); const node = element("span", "b05-profile-table__segment", ""); - // 구조물 배치로 갈라진 좁은 구간 값은 처음부터 보이지 않는다 — 블록·경계선·툴팁만 남기고, - // 그 측점을 **선택했을 때** 하이라이트와 함께 값을 보인다(2026-08-03 사용자 지시). + // 구배 행은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다 + // (2026-09-04 사용자 확정: 값 열을 접는 규칙에서 구배 행은 제외). + // 옛 규칙(2026-08-03: 구조물 구간은 고를 때만 값 표시)은 여기서 걷어냈고, + // 구조물 구간 표시(색·하이라이트)와 툴팁은 그대로 둔다. const structural = isStructureSegment(segment); - const highlighted = structural && touchesSelected(segment); if (structural) node.classList.add("is-structure"); - if (highlighted) node.classList.add("is-highlight"); - if (structural && !highlighted) { - node.style.left = `${left}px`; - node.style.width = `${span}px`; - node.title = - `${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선 (구조물 구간) -` + - `연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` + - `구배 ${segment.grade_percent.toFixed(2)}%`; - row.append(node); - return; - } + if (structural && touchesSelected(segment)) node.classList.add("is-highlight"); // 값은 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰 // 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다). const value = element("span", "b05-profile-table__segment-value", text); @@ -301,10 +301,10 @@ function curveTitle(curve: AlignmentCurve): string { function buildCurveRows( options: ProfileTableOptions, centers: number[], - /** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */ - structureStationKeys: ReadonlySet, - /** 배관 구조물 chainage 키 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ - pipeStationKeys: ReadonlySet, + /** 구조물 측점 누가거리 — 이 열의 곡선 입력은 기본 표기하지 않는다. */ + structureChainageList: ReadonlyArray, + /** 배관 구조물 누가거리 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ + pipeChainageList: ReadonlyArray, /** 기본 글자 크기(px)와 행 높이(px) — 회전 셀의 글자 축소 산식에 쓴다. */ fontPx: number, rowHeightPx: number, @@ -330,10 +330,15 @@ function buildCurveRows( // 그대로 서고, 글자 크기는 행 높이에 맞춰 줄인다(구배 행 is-rotated와 같은 방식). const curveEntries = alignment.stations.map((station, index) => { const key = station.chainage_m.toFixed(3); - const isPipe = pipeStationKeys.has(key); + const isPipe = isNearChainage(station.chainage_m, pipeChainageList); // 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지). // 예외: 배관 측점은 R이 필수 입력이라 곡선 L·R을 항상 보인다. - const curve = structureStationKeys.has(key) && !isPipe ? undefined : curveByChainage.get(key); + const found = + isNearChainage(station.chainage_m, structureChainageList) && !isPipe + ? undefined + : curveByChainage.get(key); + // 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다. + const curve = found && !isStraightCurve(found) ? found : undefined; return { center: centers[index], curve, rotated: false }; }); const withCurve = curveEntries.filter((entry) => entry.curve); @@ -471,28 +476,16 @@ function buildSelectedColumn( const right = rightSeg ? pick(rightSeg).toFixed(digits) : ""; return left && right ? { left, right } : { text: left || right || "" }; }; - const curve = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); - // 거리: 바로 앞 측점(계획선 측점)까지의 간격. - 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행 격자는 불변). + const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); + // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. + const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; + // 테이블 행 순서(구배 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 @@ -533,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; } @@ -559,23 +559,21 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 구조물(비정규) 측점 승격 변화점 = 사용자 변화점 중 규칙 측점과 겹치지 않는 것. const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3))); - const structureChainages = new Set( - alignment.pvi - .filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3))) - .map((node) => node.chainage_m.toFixed(3)), - ); + const structureChainages = alignment.pvi + .filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3))) + .map((node) => node.chainage_m); // 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다. // 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의 // chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다. - const structureStationKeys = new Set( - (options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)), - ); + const structureChainageList = (options.irregularStations ?? []).map((entry) => entry.chainage_m); + // 병합된 구조물 측점도 **구조물 구간 경계**다 — 확정으로 종단 정본에 들어간 뒤에는 + // 위 `alignment.pvi` 걸러내기에서 빠져, 고른 측점의 값 열이 강조되지 않았다 + // (2026-09-04 사용자 보고: 테이블만 함께 안 켜짐). + structureChainages.push(...structureChainageList); // 배관 구조물은 R이 필수라 곡선 행만은 기본 표기 예외다(2026-08-04 사용자 지시). - const pipeStationKeys = new Set( - (options.irregularStations ?? []) - .filter((entry) => isPipeStation(entry)) - .map((entry) => entry.chainage_m.toFixed(3)), - ); + const pipeChainageList = (options.irregularStations ?? []) + .filter((entry) => isPipeStation(entry)) + .map((entry) => entry.chainage_m); const selectedIrregularEntry = options.irregularStations?.find( (entry) => irregularStationId(entry.id) === options.selectedStationId, ); @@ -596,7 +594,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. alignment.stations.forEach((station, stationIndex) => { // 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다. - const structural = structureStationKeys.has(station.chainage_m.toFixed(3)); + const structural = isNearChainage(station.chainage_m, structureChainageList); const cell = element( "span", `b05-profile-table__cell${structural ? " is-structure" : ""}`, @@ -607,7 +605,14 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.append(row); }); table.append( - ...buildCurveRows(options, centers, structureStationKeys, pipeStationKeys, fontSize, rowHeight), + ...buildCurveRows( + options, + centers, + structureChainageList, + pipeChainageList, + fontSize, + rowHeight, + ), ); // 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index cfea791e..ea11fbce 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -10,17 +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"; @@ -60,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({ @@ -69,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(); @@ -87,20 +99,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa scroll.addEventListener("contextmenu", (event) => event.preventDefault()); // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). - scroll.addEventListener( - "wheel", - (event) => { - if (event.shiftKey || event.deltaY === 0) return; - const limit = scroll.scrollWidth - scroll.clientWidth; - if (limit <= 0) return; - const delta = event.deltaY; - if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) - return; - scroll.scrollLeft += delta; - event.preventDefault(); - }, - { passive: false }, - ); + attachWheelHorizontalScroll(scroll); /** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다 * — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */ @@ -111,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); @@ -143,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_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts new file mode 100644 index 00000000..0487a666 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -0,0 +1,203 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Tools.ts + * 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼]. + * ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.) + * + * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위 + * 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유). + * + * 선택 흐름: + * [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체. + * [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림. + * 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에 + * 라운드가 생기고 양쪽이 탄젠트 직선이 된다. + * ========================================================================== */ + +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; +import { sameRun } from "./B05_Profile_UI_Profile_Straighten"; + +export type ProfileToolMode = "none" | "straighten" | "shift"; + +export interface ProfileToolsCallbacks { + /** 두 측점을 직선으로 잇는다. */ + onStraighten: (fromChainageM: number, toChainageM: number) => void; + /** 고른 직선 구간(들)을 위·아래로 옮긴다. */ + onShift: (runs: StraightRun[], delta: number) => void; + /** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */ + onTilt: (run: StraightRun, delta: number) => void; + /** 마지막 [저장] 시점으로 되돌린다 — 전체 초기화가 아니다. */ + onResetToSaved: () => void; + canResetToSaved: () => boolean; + onUndo: () => void; + onRedo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + /** 지금 계획선에 직선화된 구간이 있는가 — [쉬프트] 안내 문구를 가른다. */ + hasStraightRuns: () => boolean; + /** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */ + onChanged: () => void; +} + +export interface ProfileTools { + /** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */ + render: () => HTMLElement; + mode: () => ProfileToolMode; + /** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */ + handleStationPick: (chainageM: number) => boolean; + /** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */ + handleRunPick: (run: StraightRun | null) => boolean; + /** 선택 중인 직선 구간들(강조 표시용). */ + selectedRuns: () => StraightRun[]; + /** 직선화 대기 중 첫 측점(강조 표시용). */ + pendingStation: () => number | null; + /** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */ + nudge: (delta: number) => boolean; + /** 모드·선택을 모두 끈다. */ + clear: () => void; +} + +function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-route-profile__tool"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; +} + +export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileTools { + let mode: ProfileToolMode = "none"; + let pending: number | null = null; + let runs: StraightRun[] = []; + + function reset(): void { + mode = "none"; + pending = null; + runs = []; + } + + function setMode(next: ProfileToolMode): void { + // 같은 버튼을 다시 누르면 모드를 끈다 — 선택도 함께 비운다. + if (mode === next) reset(); + else { + mode = next; + pending = null; + runs = []; + } + callbacks.onChanged(); + } + + function step(delta: number): boolean { + if (mode === "shift" && runs.length) { + callbacks.onShift(runs, delta); + return true; + } + if (runs.length !== 1) return false; + callbacks.onTilt(runs[0], delta); + return true; + } + + return { + mode: () => mode, + nudge: step, + selectedRuns: () => runs, + pendingStation: () => pending, + clear() { + if (mode === "none" && pending === null && !runs.length) return; + reset(); + callbacks.onChanged(); + }, + handleStationPick(chainageM) { + if (mode !== "straighten") return false; + if (pending === null) { + pending = chainageM; + callbacks.onChanged(); + return true; + } + const from = pending; + pending = null; + if (Math.abs(from - chainageM) < 1e-6) { + callbacks.onChanged(); + return true; + } + callbacks.onStraighten(from, chainageM); + return true; + }, + handleRunPick(run) { + if (mode === "none") return false; + if (!run) { + // 빈 곳을 누르면 선택만 비우고 모드는 유지한다 — 연속 조작을 끊지 않는다. + if (runs.length || pending !== null) { + runs = []; + pending = null; + callbacks.onChanged(); + } + return true; + } + const already = runs.findIndex((entry) => sameRun(entry, run)); + if (already >= 0) runs.splice(already, 1); + else if (mode === "shift") runs.push(run); + else runs = [run]; + callbacks.onChanged(); + return true; + }, + render() { + const wrap = document.createElement("span"); + wrap.className = "b05-route-profile__tools"; + + // [초기화] — 되돌리기 왼쪽. 기준은 **마지막 [저장] 시점**이고 저장한 작업은 남는다. + const reset = toolButton( + "초기화", + "마지막 [저장] 시점으로 되돌립니다 (저장한 작업은 그대로 남습니다)", + callbacks.onResetToSaved, + ); + reset.disabled = !callbacks.canResetToSaved(); + + const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo); + undo.disabled = !callbacks.canUndo(); + const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo); + redo.disabled = !callbacks.canRedo(); + + const straighten = toolButton( + "직선화", + "측점 2개를 골라 그 사이를 직선으로 만듭니다 (사이 라운드는 지워집니다)", + () => setMode("straighten"), + ); + straighten.classList.toggle("is-active", mode === "straighten"); + const shift = toolButton( + "쉬프트", + "직선화된 라인을 골라(여러 개 가능) 위·아래로 옮깁니다", + () => setMode("shift"), + ); + shift.classList.toggle("is-active", mode === "shift"); + + const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1)); + const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1)); + const idle = mode === "none" || (!runs.length && mode === "shift"); + up.disabled = idle || (mode === "straighten" && runs.length !== 1); + down.disabled = up.disabled; + + wrap.append(reset, undo, redo, straighten, shift, up, down); + if (mode === "straighten" && pending !== null) { + const hint = document.createElement("em"); + hint.className = "b05-route-profile__tool-hint"; + hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`; + wrap.append(hint); + } else if (mode === "shift" && !runs.length) { + const hint = document.createElement("em"); + hint.className = "b05-route-profile__tool-hint"; + // 고를 것이 아예 없는 경우와 있는데 안 고른 경우를 갈라 적는다 — 초기 계획선은 + // 마디마다 기울기가 달라 직선 구간이 없어, 눌러도 안 잡히는 것이 정상이다. + hint.textContent = callbacks.hasStraightRuns() + ? "직선화된 라인을 고르세요 (여러 개 가능) — 끝내려면 [쉬프트]를 다시 누르세요" + : "직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요"; + wrap.append(hint); + } + return wrap; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts new file mode 100644 index 00000000..d174fc83 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -0,0 +1,178 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Wheel.ts + * 세로 휠 → 가로 이동. 종단 그래프·유토곡선·측점 테이블 세 스크롤러가 같은 문법을 쓴다 + * (2026-08-04 사용자 지시). 셋에 똑같이 복사돼 있던 것을 한 벌로 모았다(2026-09-04). + * + * **부드럽게 이동**(2026-09-04 사용자 지시) — 예전처럼 `scrollLeft`에 곧바로 더하면 휠 + * 한 칸(120px)이 통째로 점프해 툭툭 끊긴다. + * + * 브라우저 기본 `scrollTo({ behavior: "smooth" })`는 쓰지 않는다. 휠을 굴리는 동안 매번 + * 애니메이션이 **처음부터 다시 시작**돼 느린 구간만 반복하기 때문이다 — 실측으로 1.6초 + * 동안 1,200px 중 17px 만 움직였다(2026-09-04). 대신 목표만 쌓아 두고 프레임마다 남은 + * 거리의 일정 비율씩 좁힌다. 목표가 늘어나도 속도가 이어지므로 굴릴수록 뒤처지지 않는다. + * + * Shift+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다. + * + * **끌어서 이동**(2026-09-06 사용자 지시) — 스크롤바를 잡지 않고 그림 위에서 바로 끌어 + * 옮긴다. 좌클릭·휠 버튼 둘 다 되며, 여기 한 곳만 고치면 이것을 쓰는 세 곳(종단 그래프· + * 유토곡선·측점 테이블)이 함께 얻는다. + * ========================================================================== */ + +/** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — + * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ +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라 그대로 + * 쓰면 한 번에 목표까지 튄다. */ +const MAX_FRAME_MS = 64; + +export function attachWheelHorizontalScroll(scroller: HTMLElement): void { + let target = -1; + /** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */ + let applied = -1; + let frame = 0; + /** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */ + let lastFrameAt = 0; + + function stop(): void { + if (frame) cancelAnimationFrame(frame); + frame = 0; + target = -1; + applied = -1; + lastFrameAt = 0; + } + + function step(now: number): void { + frame = 0; + // 스크롤바 끌기·스크롤 동기화처럼 다른 경로가 자리를 옮겼으면 이동을 접는다. + if (applied >= 0 && Math.abs(scroller.scrollLeft - applied) > 1) return stop(); + const limit = scroller.scrollWidth - scroller.clientWidth; + const goal = Math.min(limit, Math.max(0, target)); + const rest = goal - scroller.scrollLeft; + if (Math.abs(rest) <= SNAP_PX) { + scroller.scrollLeft = goal; + return stop(); + } + // 프레임당 고정 비율로 좁히면 화면 주사율에 따라 속도가 달라진다 — 120Hz에서는 두 배 + // 빨라 툭 끊겨 보이고, 프레임을 한 번 놓치면 그만큼 덜컥 뛴다. 흐른 시간으로 환산해 + // **어느 화면에서나 같은 시간**에 도착하게 한다(2026-09-04). + const elapsed = lastFrameAt ? Math.min(MAX_FRAME_MS, now - lastFrameAt) : BASE_FRAME_MS; + lastFrameAt = now; + const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS); + scroller.scrollLeft = scroller.scrollLeft + rest * ratio; + applied = scroller.scrollLeft; + frame = requestAnimationFrame(step); + } + + scroller.addEventListener( + "wheel", + (event) => { + if (event.shiftKey || event.deltaY === 0) return; + const limit = scroller.scrollWidth - scroller.clientWidth; + if (limit <= 0) return; + // 이동 중이면 **직전 목표**에 이어 더한다 — 현재 위치에 더하면 아직 못 간 거리를 + // 잃어 굴릴수록 뒤처진다. + const from = frame ? target : scroller.scrollLeft; + const next = Math.min(limit, Math.max(0, from + event.deltaY)); + if (next === from && (next <= 0 || next >= limit)) return; + target = next; + if (!frame) { + applied = scroller.scrollLeft; + frame = requestAnimationFrame(step); + } + event.preventDefault(); + }, + { 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_Profile_Zoom.ts b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts new file mode 100644 index 00000000..fb29af27 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Zoom.ts + * 종단면도 줌 조작구 — 버튼 셋(줌인·줌아웃·초기화), 2026-09-04 사용자 확정. + * + * 공사 범위가 넓으면 종단 그래프가 눌려 읽히지 않는다. 사람이 맞출 것은 **가로 하나**다. + * + * X (가로) — **폭 배수**다. SVG transform 으로 늘리면 그래프만 커지고 측점 테이블· + * 계획고 편집 버튼층·구조물 알약 레인이 어긋난다(넷이 같은 `chainageMapper` + * 를 쓴다). 캔버스 폭 자체를 키우고 가로 스크롤로 훑는다. + * Y (세로) — **프로그램이 자동으로 맞춘다**. 보이는 구간의 지반·계획선 범위에 맞춰 + * 잡으므로(`windowElevationRange`) 세로 배율·창 이동 버튼이 필요 없어졌다. + * 옛 `⇕+`·`⇕−`·`▲`·`▼` 네 버튼은 그래서 없앴다. + * + * **배율 1 = 기본값이자 축소 한계**(사용자 확정) — 폭맞춤보다 더 줄이면 측점이 겹쳐 + * 읽을 수 없다. 한계에 닿은 버튼은 흐리게 죽인다. + * + * 배율은 페이지가 들고 있다 — 편집·재계산으로 다시 그려도 유지된다(B06 `cardZoomStates` 규칙). + * ========================================================================== */ + +/** 한 번 누를 때 배율 배수. 횡단도 줌(1/0.85)보다 성글게 — 폭 배수라 한 칸이 크게 느껴진다. */ +const ZOOM_STEP = 1.25; +/** 가로 폭 배수 상한 — 이 이상은 캔버스가 수만 px이 되어 브라우저가 버겁다. */ +const MAX_X = 8; + +export interface ProfileZoomState { + /** 가로 폭 배수(1 = 현행 폭맞춤 = 기본값·축소 한계). */ + x: number; +} + +export interface ProfileZoom { + /** 절·성토 요약줄 오른쪽 끝에 붙는 버튼 묶음(한 번 만들어 계속 쓴다). */ + bar: HTMLElement; + state: () => ProfileZoomState; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +export function createProfileZoom(onChange: () => void): ProfileZoom { + const state: ProfileZoomState = { x: 1 }; + + const bar = document.createElement("div"); + bar.className = "b05-profile__zoom"; + + function add(label: string, title: string, action: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-profile__zoom-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드·측점 선택으로 번지면 그래프를 다시 그리며 방금 맞춘 배율이 날아간다. + event.stopPropagation(); + action(); + syncDisabled(); + onChange(); + }); + bar.append(button); + return button; + } + + const zoomIn = add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (세로는 자동으로 맞춥니다)", () => { + state.x = clamp(state.x * ZOOM_STEP, 1, MAX_X); + }); + const zoomOut = add("−", "가로 축소 — 기본 폭(화면 맞춤)까지만 줄어듭니다", () => { + state.x = clamp(state.x / ZOOM_STEP, 1, MAX_X); + }); + add("⤢", "기본 상태로 — 가로 폭맞춤, 세로 자동", () => { + state.x = 1; + }); + + /** 한계에 닿은 버튼은 눌러도 변화가 없다 — 흐리게 죽여 그 사실을 보인다. */ + function syncDisabled(): void { + zoomOut.disabled = state.x <= 1 + 1e-9; + zoomIn.disabled = state.x >= MAX_X - 1e-9; + } + syncDisabled(); + + return { bar, state: () => ({ ...state }) }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts new file mode 100644 index 00000000..8b644721 --- /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 { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { + bindRouteEditNavigation, + contourBandRect, + handleAtScreen, + nodeAtScreen, + segmentAtScreen, +} from "./B05_Profile_UI_RouteEdit_Input"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, +} from "./B05_Profile_UI_RouteEdit_Label"; +import { + applyArcLocks, + curveSummary, + flattenServerPlan, + type CurveLock, +} from "./B05_Profile_UI_RouteEdit_Edits"; +import { + bindHistoryControls, + createRouteEditHistory, + type RouteEditHistory, + type RouteEditSnapshot, +} from "./B05_Profile_UI_RouteEdit_History"; +import "./B05_Profile_UI_Style_RouteEdit.css"; + +/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ +const NODE_HIT_PX = 9; +/** 노드 반지름(px). */ +const NODE_R = 4; +/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ +const SEGMENT_HIT_PX = 12; +/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). + * + * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 + * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 + * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ +const CONTOUR_BAND_M = 300; +/** 곡선 시작·끝점 손잡이 크기(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 = []; + /** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m). + * 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */ + let curveLock: CurveLock[] = []; + let curveArc: Array = []; + /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ + let picked = -1; + /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ + let history: RouteEditHistory | null = null; + 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); + historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. + curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. + 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(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = meta + ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) + : null; + if (band) { + context.beginPath(); + context.rect(band.x, band.y, band.width, band.height); + context.clip(); + } + 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) => { + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. + const on = curveOn[curve.node_first] !== false; + if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === picked; + [curve.start, curve.end].forEach((point) => { + const [x, y] = toScreen([point[0], point[1]]); + context.beginPath(); + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + context.fill(); + context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.stroke(); + }); + }); + context.restore(); + // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. + syncCurveBar(); + } + + /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ + const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => + handleAtScreen( + // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). + curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null), + toScreen, + px, + py, + NODE_HIT_PX + 2, + ); + const nodeAt = (px: number, py: number): number => + nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); + const segmentAt = (px: number, py: number): number => + segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX); + + /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ + function dragHandleTo( + node: number, + which: "start" | "end", + to: Vertex, + ): { apex: Vertex; radius: number } | null { + const curve = curveInfo.find((entry) => entry.node_first === node); + if (!curve) return null; + 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); + } + + /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). + * + * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 + * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. + * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 + * 서버가 정본으로 다시 내는 것은 그대로다. */ + function markEdited(): void { + // 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다. + applyArcLocks(planned, curveLock, curveArc, curveRadius); + const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); + plannedLine = built.vertices; + curveInfo = built.curves; + nodeInfo = built.nodes; + } + + /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ + const curveHint = (): string => + curveSummary({ + nodeCount: planned.length, + curveOn, + curveRadius, + curveLock, + curveCount: curveInfo.length, + violationCount: nodeInfo.filter((node) => node.violations.length).length, + minRadiusM, + fresh: nodeInfo.length === 0, + }); + + // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── + const curveLabelBox = createCurveLabel({ + onRadius: (value) => { + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onArcLength: (value) => { + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveArc[picked] = value; + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); + }, + onLock: (lock) => { + if (picked < 0) return; + curveLock[picked] = lock; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null; + // 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다. + if (lock === "arc") { + curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null; + } + // R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음). + if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown; + applyEdit( + lock === "radius" + ? "반지름을 고정했습니다." + : lock === "arc" + ? "곡선 길이를 고정했습니다." + : "고정을 풀었습니다.", + ); + }, + onCurveOn: (on) => { + if (picked < 0) return; + curveOn[picked] = on; + applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + }); + + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ + function syncCurveBar(): void { + if (!(picked > 0 && picked < planned.length - 1)) { + curveLabelBox.hide(); + return; + } + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + const rect = canvas.getBoundingClientRect(); + const [screenX, screenY] = toScreen(planned[picked]); + curveLabelBox.show({ + seat: picked, + // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다. + at: [screenX + rect.left, screenY + rect.top], + centerDirection: pickedCurve + ? centerDirectionOf( + toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + toScreen(pickedCurve.start), + toScreen(pickedCurve.end), + ) + : null, + curveOn: curveOn[picked] !== false, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, + lock: curveLock[picked] ?? null, + innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + }); + } + + /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ + function applyEdit(message: string, record = true): void { + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`; + draw(); + if (record) history?.commit(snapshotNow()); + historyControls.sync(); + } + + /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ + function snapshotNow(): RouteEditSnapshot { + return { planned, curveOn, curveRadius, curveLock, curveArc, picked }; + } + + const historyControls = bindHistoryControls({ + overlay, + getHistory: () => history, + restore: (snapshot, message) => { + planned = snapshot.planned; + curveOn = snapshot.curveOn; + curveRadius = snapshot.curveRadius; + curveLock = snapshot.curveLock; + curveArc = snapshot.curveArc; + picked = snapshot.picked; + applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. + }, + }); + + // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. + let dragNode = -1; + /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ + let dragHandle: { node: number; end: "start" | "end" } | null = null; + /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 + * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ + let dragMoved = false; + + 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; + // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 + // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 + // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. + dragNode = nodeAt(px, py); + dragHandle = dragNode >= 0 ? null : handleAt(px, py); + dragMoved = false; + if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else if (dragHandle) { + picked = dragHandle.node; + syncCurveBar(); + draw(); + } + 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 node = dragHandle.node; + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); + if (moved) { + planned[node] = moved.apex; + curveRadius[node] = Math.round(moved.radius * 100) / 100; + curveOn[node] = true; + picked = node; + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + dragMoved = true; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; + draw(); + } + return; + } + if (dragNode >= 0) { + dragMoved = true; + planned[dragNode] = toMetric(px, py); + markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 + // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). + status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; + draw(); + return; + } + canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default"; + }); + + const endDrag = (event: PointerEvent): void => { + if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 + // 눌러야 한다. 놓는 순간에만 쌓는다. + if (dragMoved) { + history?.commit(snapshotNow()); + historyControls.sync(); + } + dragNode = -1; + dragHandle = null; + dragMoved = false; + }; + 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); + curveLock.splice(segment + 1, 0, null); + curveArc.splice(segment + 1, 0, null); + picked = segment + 1; + applyEdit("새 노드를 넣었습니다(직선 추가)."); + }); + + 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); + curveLock.splice(index, 1); + curveArc.splice(index, 1); + picked = -1; + applyEdit("노드를 지웠습니다(직선 삭제)."); + }); + + // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => { + view = next; + }, + getMeta: () => meta, + draw, + }); + + 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; + // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). + const flat = flattenServerPlan(nodes, plan.curves ?? []); + planned = flat.planned; + nodeInfo = flat.nodes; + curveInfo = flat.curves; + curveOn = flat.curveOn; + curveRadius = flat.curveRadius; + curveLock = flat.curveLock; + curveArc = flat.curveArc; + picked = -1; + // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. + history = createRouteEditHistory(snapshotNow()); + historyControls.sync(); + 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..4a35d75b --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -0,0 +1,303 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Curve.ts + * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다. + * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07) + * + * 두 가지가 들어 있다. + * + * ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는 + * 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * + * ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**: + * `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중 + * **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다. + * 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`. + * + * 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는 + * 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아 + * 있는데 화면만 「지워졌다」고 말하니 더 나쁘다. 조작 중에는 브라우저가 같은 규칙으로 + * 즉시 그리고, [확인] 때 서버가 정본으로 다시 낸다(CLAUDE.md 「계산 자리」 ① 짝). + * + * 옮기지 않은 것 — 단순화·IP 추출·반지름 피팅(`_fit_radius_m`)은 **예상노선을 처음 + * 폴리라인으로 바꿀 때만** 쓰는 것이라 편집 갈래에서는 돌지 않는다(`node_indices` 가 None). + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 곡선 성분 하나 — 서버 `RoutePlanCurve` 와 **같은 꼴**이라 그대로 바꿔 쓸 수 있다. + * + * ⚠ 여기서 다시 적는 이유 — 이 파일은 거울 시험이 `tsc` 로 **혼자 컴파일**하므로 바깥 + * 모듈을 들이지 않는다(경로 별칭 `@config/…` 가 딸려 와 컴파일이 깨진다). */ +export interface EditedCurve { + 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[]; +} + +/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */ +const DUPLICATE_TOLERANCE_M = 0.5; +/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */ +const ARC_STEP_DEG = 5.0; +/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면 + * 90° 가 89.999…9 로 떨어져 원호 점 수가 하나 어긋난다(2026-09-07 거울 시험에서 실제로 남). */ +const DEG_PER_RAD = 180 / Math.PI; +const RAD_PER_DEG = Math.PI / 180; +/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */ +export const HAIRPIN_MIN_RADIUS_M = 10.0; + +/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 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) * DEG_PER_RAD; +} + +/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. + * + * 사용자 확정(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) * RAD_PER_DEG) / 2); + 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 }; +} + +const distance = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/** 짝: `_unit`. from → to 방향의 단위벡터. 같은 자리면 (0,0). */ +function unit(from: Vertex, to: Vertex): Vertex { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const length = Math.hypot(dx, dy); + if (length <= 0) return [0, 0]; + return [dx / length, dy / length]; +} + +/** 짝: `_turn_sign`. 도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0. */ +function turnSign(before: Vertex, at: Vertex, after: Vertex): number { + const cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]); + if (Math.abs(cross) <= 1e-9) return 0; + return cross > 0 ? 1 : -1; +} + +/** 짝: `_arc_geometry`. 반지름 하나에 대한 (접선시작, 접선끝, 중심). 못 끼우면 null. */ +function arcGeometry( + before: Vertex, + at: Vertex, + after: Vertex, + innerDeg: number, + radius: number, + halfTan: number, +): { start: Vertex; end: Vertex; center: Vertex } | null { + const tangent = radius * halfTan; + const toBefore = unit(at, before); + const toAfter = unit(at, after); + const start: Vertex = [at[0] + toBefore[0] * tangent, at[1] + toBefore[1] * tangent]; + const end: Vertex = [at[0] + toAfter[0] * tangent, at[1] + toAfter[1] * tangent]; + const bisector: Vertex = [toBefore[0] + toAfter[0], toBefore[1] + toAfter[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + if (bisectorLength <= 1e-9) return null; + const centerDistance = radius / Math.sin((innerDeg * RAD_PER_DEG) / 2); + const center: Vertex = [ + at[0] + (bisector[0] / bisectorLength) * centerDistance, + at[1] + (bisector[1] / bisectorLength) * centerDistance, + ]; + return { start, end, center }; +} + +/** 짝: `_arc_points`. 원호 위 점(양 끝은 빼고 — 부르는 쪽이 붙인다). */ +function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolean): Vertex[] { + const radius = distance(center, start); + if (radius <= 0) return []; + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]); + const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]); + let sweep = endAngle - startAngle; + if (clockwise) { + while (sweep > 0) sweep -= 2 * Math.PI; + } else { + while (sweep < 0) sweep += 2 * Math.PI; + } + const steps = Math.max(1, Math.trunc(Math.abs(sweep * DEG_PER_RAD) / ARC_STEP_DEG)); + const points: Vertex[] = []; + for (let step = 1; step < steps; step += 1) { + const angle = startAngle + (sweep * step) / steps; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + +/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ +export interface EditedNode { + inner_angle_deg: number | null; + radius_m: number | null; + tangent_m: number | null; + violations: string[]; +} + +export interface EditedPolyline { + /** 그려 보이는 선(원호 포함). */ + vertices: Vertex[]; + /** 곡선 성분 — 손잡이·R 칸의 재료. `node_first`/`node_last` 는 **넘긴 목록의 자리**다. */ + curves: EditedCurve[]; + /** 넘긴 목록과 **자리가 같은** 노드 요약. */ + nodes: EditedNode[]; +} + +/** + * 짝: `build_planned_polyline` 의 편집 갈래. 노드·곡선 켬끔·반지름으로 폴리라인을 만든다. + * + * `curveOn[i]` 가 거짓이면 그 자리에 곡선을 두지 않는다(직선이 그대로 꺾인다). + * `curveRadius[i]` 가 있으면 그 반지름으로 못박고, 없으면 법정 하한을 쓴다. + * 접선 자리가 모자라면 **줄이되 막지 않고** 위반으로 표시한다(서버와 같은 규칙). + * + * ⚠ 서버는 0.5m 안에 겹친 점을 버린다. 여기서도 같이 버리되, 돌려주는 자리 번호는 + * **넘긴 목록 기준**으로 되돌려 놓는다 — 화면이 잡고 있는 배열이 그것이기 때문이다. + */ +export function buildEditedPolyline( + points: Vertex[], + curveOn: boolean[], + curveRadius: Array, + minRadiusM: number, + hairpinMinRadiusM: number = HAIRPIN_MIN_RADIUS_M, +): EditedPolyline { + const nodes: EditedNode[] = points.map(() => ({ + inner_angle_deg: null, + radius_m: null, + tangent_m: null, + violations: [], + })); + + // 겹친 점 버리기 — 편집값과 **함께** 걸러야 자리가 어긋나지 않는다. + const cleaned: Vertex[] = []; + const flags: boolean[] = []; + const forcedRadii: Array = []; + const origin: number[] = []; // cleaned 자리 → 넘긴 목록 자리 + points.forEach((point, index) => { + if (cleaned.length && distance(cleaned[cleaned.length - 1], point) <= DUPLICATE_TOLERANCE_M) { + return; + } + cleaned.push(point); + flags.push(curveOn[index] !== false); + forcedRadii.push(curveRadius[index] ?? null); + origin.push(index); + }); + + if (cleaned.length < 3) return { vertices: [...cleaned], curves: [], nodes }; + + for (let index = 1; index < cleaned.length - 1; index += 1) { + nodes[origin[index]].inner_angle_deg = innerAngleDeg( + cleaned[index - 1], + cleaned[index], + cleaned[index + 1], + ); + } + + const vertices: Vertex[] = [cleaned[0]]; + const curves: EditedCurve[] = []; + let cursor = 0; // 아직 선에 안 실은 첫 꺾임점 + + for (let at = 1; at < cleaned.length - 1; at += 1) { + if (!flags[at]) continue; // 곡선을 지운 자리 — 직선이 그대로 꺾인다. + const entryFrom = cleaned[at - 1]; + const apex = cleaned[at]; + const exitTo = cleaned[at + 1]; + const node = nodes[origin[at]]; + + const inner = innerAngleDeg(entryFrom, apex, exitTo); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); + // 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + const available = Math.min(distance(entryFrom, apex), distance(apex, exitTo)) / 2; + if (!(halfTan > 1e-9) || available <= 0) continue; + + const forced = forcedRadii[at]; + let radius = forced !== null && forced > 0 ? forced : minRadiusM; + let tangent = radius * halfTan; + if (tangent > available) { + radius = available / halfTan; + tangent = available; + } + + const geometry = arcGeometry(entryFrom, apex, exitTo, inner, radius, halfTan); + if (radius <= 0 || geometry === null) continue; + + if (radius < minRadiusM) { + node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`); + } + if (radius < hairpinMinRadiusM) { + node.violations.push( + `배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`, + ); + } + node.radius_m = radius; + node.tangent_m = tangent; + curves.push({ + apex: [apex[0], apex[1]], + radius_m: radius, + tangent_m: tangent, + inner_angle_deg: inner, + start: [geometry.start[0], geometry.start[1]], + end: [geometry.end[0], geometry.end[1]], + node_first: origin[at], + node_last: origin[at], + violations: [...node.violations], + }); + + // 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다. + for (let index = cursor + 1; index < at; index += 1) vertices.push(cleaned[index]); + cursor = at; + + const clockwise = turnSign(entryFrom, apex, exitTo) < 0; + vertices.push(geometry.start); + vertices.push(...arcPoints(geometry.center, geometry.start, geometry.end, clockwise)); + vertices.push(geometry.end); + } + + for (let index = cursor + 1; index < cleaned.length; index += 1) vertices.push(cleaned[index]); + return { vertices, curves, nodes }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts new file mode 100644 index 00000000..a21e6987 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Edits.ts + * 꺾임점마다의 **편집값**을 다루는 순수 함수 — 잠금과 상태줄 요약. + * + * **잠금이 왜 필요한가**(2026-09-07 사용자 지시) — 노드를 옮기면 앞뒤 직선의 교각(Δ)이 + * 바뀐다. 반지름 R 과 곡선 길이 L 은 **L = R·Δ** 로 묶여 있으므로, 한쪽을 붙들면 다른 쪽은 + * 반드시 따라 움직인다. 둘 다 붙들 수는 없다(그러면 Δ 를 못 바꾼다). 그래서 잠금은 셋 중 하나다. + * + * · `null` — 자동. R 은 법정 하한을 쓰고 L 은 따라온다. + * · `"radius"` — **R 고정**. 노드를 옮겨도 R 이 그대로고 L 이 바뀐다. + * · `"arc"` — **곡선 길이 고정**. 노드를 옮기면 R 을 L/Δ 로 다시 잡는다. + * + * ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`). + * 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다. + * ========================================================================== */ + +import { + innerAngleDeg, + type EditedCurve, + type EditedNode, + type Vertex, +} from "./B05_Profile_UI_RouteEdit_Curve"; +import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label"; + +/** 무엇을 붙들고 있나. */ +export type CurveLock = "radius" | "arc" | null; + +/** + * **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침). + * + * 노드를 옮길 때마다 부른다. 잠그지 않았거나 R 을 잠근 자리는 손대지 않는다. + * 교각이 0 에 가까우면(거의 직선) 길이를 지킬 방법이 없으므로 그대로 둔다. + */ +export function applyArcLocks( + planned: Vertex[], + curveLock: CurveLock[], + curveArc: Array, + curveRadius: Array, +): void { + for (let seat = 1; seat < planned.length - 1; seat += 1) { + if (curveLock[seat] !== "arc") continue; + const length = curveArc[seat]; + if (length === null || length === undefined) continue; + const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]); + const deflection = deflectionRad(inner); + if (deflection <= 1e-9) continue; + curveRadius[seat] = length / deflection; + } +} + +export interface CurveSummaryInput { + nodeCount: number; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + /** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */ + curveCount: number; + /** 법정 기준을 못 맞춘 자리 수. */ + violationCount: number; + minRadiusM: number; + /** 아직 한 번도 안 그렸나(막 열었을 때). */ + fresh: boolean; +} + +/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */ +export function curveSummary(input: CurveSummaryInput): string { + const off = input.curveOn.filter( + (on, index) => !on && index > 0 && index < input.nodeCount - 1, + ).length; + const forced = input.curveRadius.filter((value) => value !== null).length; + const locked = input.curveLock.filter((lock) => lock !== null).length; + const edits = [ + off ? `곡선 지움 ${off}곳` : "", + forced ? `R 지정 ${forced}곳` : "", + locked ? `고정 ${locked}곳` : "", + ] + .filter(Boolean) + .join(" · "); + if (input.fresh) { + const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [확인] 때 반영` : ""; + return edits ? `${base}${base ? " · " : ""}${edits}` : base; + } + return ( + `곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` + + `${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` + + `${edits ? ` · ${edits}` : ""}` + ); +} + +/** 서버가 준 노드·곡선을 **편집할 수 있는 꼴**로 편 결과. */ +export interface FlattenedPlan { + planned: Vertex[]; + nodes: EditedNode[]; + curves: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; +} + +interface ServerNode { + x: number; + y: number; + radius_m: number | null; + inner_angle_deg: number | null; + violations?: string[]; +} + +/** + * 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07). + * + * 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 + * 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도 + * 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R + * 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은 + * 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**. + * + * ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안 + * 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집 + * 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다. + */ +export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan { + 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); + } + }); + + const out: FlattenedPlan = { + planned: [], + nodes: [], + curves: [], + curveOn: [], + curveRadius: [], + curveLock: [], + curveArc: [], + }; + nodes.forEach((node, index) => { + if (dropped.has(index)) return; + const curve = replaced.get(index); + const seat = out.planned.length; + out.planned.push(curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]); + out.nodes.push({ + radius_m: curve ? curve.radius_m : node.radius_m, + inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, + tangent_m: null, + violations: curve ? (curve.violations ?? []) : (node.violations ?? []), + }); + out.curveOn.push(curve !== undefined); + // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. + out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); + out.curveLock.push(null); + out.curveArc.push(null); + if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); + }); + return out; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts new file mode 100644 index 00000000..9db4946c --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -0,0 +1,169 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_History.ts + * 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시). + * + * 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니 + * **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로 + * 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다. + * + * 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로 + * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 되돌릴 수 있는 편집 상태 한 벌. */ +export interface RouteEditSnapshot { + planned: Vertex[]; + curveOn: boolean[]; + curveRadius: Array; + /** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */ + curveLock: Array<"radius" | "arc" | null>; + /** 길이를 붙들었을 때의 그 길이(m). */ + curveArc: Array; + picked: number; +} + +/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */ +const MAX_STEPS = 100; + +export interface RouteEditHistory { + /** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */ + commit: (snapshot: RouteEditSnapshot) => void; + /** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */ + undo: () => RouteEditSnapshot | null; + /** 한 걸음 앞으로. 없으면 null. */ + redo: () => RouteEditSnapshot | null; + /** 창을 연 상태로. 이미 그 상태면 null. */ + reset: () => RouteEditSnapshot | null; + canUndo: () => boolean; + canRedo: () => boolean; + /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ + isDirty: () => boolean; +} + +/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */ +function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { + return { + planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), + curveOn: [...snapshot.curveOn], + curveRadius: [...snapshot.curveRadius], + curveLock: [...snapshot.curveLock], + curveArc: [...snapshot.curveArc], + picked: snapshot.picked, + }; +} + +/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ +function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { + if (a.planned.length !== b.planned.length) return false; + for (let index = 0; index < a.planned.length; index += 1) { + if (a.planned[index][0] !== b.planned[index][0]) return false; + if (a.planned[index][1] !== b.planned[index][1]) return false; + if (a.curveOn[index] !== b.curveOn[index]) return false; + if (a.curveRadius[index] !== b.curveRadius[index]) return false; + if (a.curveLock[index] !== b.curveLock[index]) return false; + if (a.curveArc[index] !== b.curveArc[index]) return false; + } + return true; +} + +/** 첫 사진(창을 연 상태)으로 이력을 연다. */ +export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory { + const steps: RouteEditSnapshot[] = [clone(initial)]; + let at = 0; + + return { + commit(snapshot) { + // 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로. + steps.length = at + 1; + steps.push(clone(snapshot)); + if (steps.length > MAX_STEPS) steps.shift(); + at = steps.length - 1; + }, + undo() { + if (at <= 0) return null; + at -= 1; + return clone(steps[at]); + }, + redo() { + if (at >= steps.length - 1) return null; + at += 1; + return clone(steps[at]); + }, + reset() { + // 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다. + if (sameRoute(steps[at], steps[0])) return null; + // 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다. + steps.length = at + 1; + steps.push(clone(steps[0])); + at = steps.length - 1; + return clone(steps[at]); + }, + canUndo: () => at > 0, + canRedo: () => at < steps.length - 1, + isDirty: () => !sameRoute(steps[at], steps[0]), + }; +} + +export interface HistoryControlsParams { + /** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */ + overlay: HTMLElement; + /** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */ + getHistory: () => RouteEditHistory | null; + /** 사진 한 벌을 화면에 되살린다. */ + restore: (snapshot: RouteEditSnapshot, message: string) => void; +} + +/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. + * + * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을 + * 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */ +export function bindHistoryControls(params: HistoryControlsParams): { + sync: () => void; + dispose: () => void; +} { + const { overlay, getHistory, restore } = params; + const undoBtn = overlay.querySelector('[data-act="undo"]')!; + const redoBtn = overlay.querySelector('[data-act="redo"]')!; + const resetBtn = overlay.querySelector('[data-act="history-reset"]')!; + + const sync = (): void => { + const history = getHistory(); + undoBtn.disabled = !history?.canUndo(); + redoBtn.disabled = !history?.canRedo(); + resetBtn.disabled = !history?.isDirty(); + }; + + const step = (which: "undo" | "redo" | "reset"): void => { + const history = getHistory(); + if (!history) return; + const snapshot = history[which](); + if (!snapshot) return; + restore( + snapshot, + which === "undo" + ? "되돌렸습니다." + : which === "redo" + ? "다시 했습니다." + : "창을 연 상태로 돌렸습니다.", + ); + sync(); + }; + + undoBtn.addEventListener("click", () => step("undo")); + redoBtn.addEventListener("click", () => step("redo")); + resetBtn.addEventListener("click", () => step("reset")); + + const onKey = (event: KeyboardEvent): void => { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "z" && !event.shiftKey) step("undo"); + else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + + return { sync, dispose: () => window.removeEventListener("keydown", onKey) }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts new file mode 100644 index 00000000..89555c43 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Input.ts + * 노선 편집 모달의 **지도 조작** — 휠 확대/축소와 가운데 버튼 팬. + * + * ⚠ 배수유역도(`B05_Profile_UI_Drainage_Interact.ts`)와 **같은 동작**이어야 한다 + * (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다. + * · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87 + * · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5 + * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가 + * 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. + * + * ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고 + * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). + * ========================================================================== */ + +import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */ +const ZOOM_IN_FACTOR = 1.15; +const ZOOM_OUT_FACTOR = 0.87; +/** 배율 하한 — 배수유역도와 같다(전체보다 조금 더 뒤로 뺄 수 있게). */ +const MIN_SCALE = 0.5; +/** 배율 상한을 못 구할 때 쓸 값 — 배수유역도와 같은 자리에 같은 수. */ +const MAX_SCALE_FALLBACK = 16; + +export interface RouteEditNavigationParams { + canvas: HTMLCanvasElement; + getView: () => ViewState; + setView: (next: ViewState) => void; + getMeta: () => VWorldMeta | null; + draw: () => void; +} + +/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */ +export function bindRouteEditNavigation(params: RouteEditNavigationParams): void { + const { canvas, getView, setView, getMeta, draw } = params; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const view = getView(); + const maxScale = computeMaxScale( + getMeta(), + view.mapRect.width, + view.width, + MAX_SCALE_FALLBACK, + ); + const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; + const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor)); + const ratio = scale / view.scale; + const rect = canvas.getBoundingClientRect(); + // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + setView({ + ...view, + scale, + offsetX: cursorX * (1 - ratio) + view.offsetX * ratio, + offsetY: cursorY * (1 - ratio) + view.offsetY * ratio, + }); + draw(); + }, + { passive: false }, + ); + + canvas.addEventListener("pointerdown", (event) => { + // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. + if (event.pointerType === "mouse" && event.button !== 1) return; + const view = getView(); + dragStart = { + x: event.clientX, + y: event.clientY, + offsetX: view.offsetX, + offsetY: view.offsetY, + }; + canvas.style.cursor = "grabbing"; + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener("pointermove", (event) => { + if (!dragStart) return; + setView({ + ...getView(), + offsetX: dragStart.offsetX + event.clientX - dragStart.x, + offsetY: dragStart.offsetY + event.clientY - dragStart.y, + }); + draw(); + }); + + const stop = (): void => { + if (!dragStart) return; + dragStart = null; + canvas.style.removeProperty("cursor"); + }; + canvas.addEventListener("pointerup", stop); + canvas.addEventListener("pointercancel", stop); +} + +/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */ + +export type ScreenOf = (point: [number, number]) => [number, number]; + +/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ +export function nodeAtScreen( + points: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number, +): number { + let best = -1; + let bestDistance = hitPx; + points.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; +} + +/** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null. + * + * 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는 + * 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07). + * 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 + * 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */ +export function handleAtScreen( + curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number, +): { node: number; end: "start" | "end" } | null { + let best: { node: number; end: "start" | "end" } | null = null; + let bestDistance = hitPx; + curves.forEach((curve) => { + (["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 = { node: curve.node_first, end: which }; + } + }); + }); + return best; +} + +/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */ +export function segmentAtScreen( + points: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + maxPx: number, +): number { + let best = -1; + let bestDistance = maxPx; + for (let index = 0; index < points.length - 1; index += 1) { + const [ax, ay] = toScreen(points[index]); + const [bx, by] = toScreen(points[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; +} + +/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 + * 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */ +export function contourBandRect( + line: Array<[number, number]>, + toScreen: ScreenOf, + bandM: number, +): { x: number; y: number; width: number; height: number } | null { + if (line.length < 2) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of line) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. + const [left, bottom] = toScreen([minX - bandM, minY - bandM]); + const [right, top] = toScreen([maxX + bandM, maxY + bandM]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts new file mode 100644 index 00000000..b1a37a79 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -0,0 +1,242 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Label.ts + * 고른 꺾임점 옆에 뜨는 **곡선 조작 패널** — 반지름·곡선 길이를 보고 고치고 붙든다. + * + * 왜 옆인가(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이라 지금 고른 것이 + * 지도 어디인지 눈으로 안 이어졌다. + * + * **R 과 곡선 길이는 한 쌍**(L = R·Δ, Δ 는 앞뒤 직선이 정하는 교각) — 한쪽을 고치면 다른 + * 쪽이 따라온다. 「자동」 단추는 없다: 칸을 비우는 것이 곧 자동이다. 어느 쪽을 붙들지는 + * **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고). + * + * **자리**(2026-09-07 사용자 지시) + * · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라 + * 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다. + * · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다). + * · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고, + * 다른 꺾임점을 고르면 자동 자리로 돌아간다. + * ========================================================================== */ + +import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits"; + +/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */ +export function deflectionRad(innerAngleDeg: number | null | undefined): number { + if (innerAngleDeg === null || innerAngleDeg === undefined) return 0; + return ((180 - innerAngleDeg) * Math.PI) / 180; +} + +/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 패널을 그 반대쪽에 붙이는 데 쓴다. + * + * 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라 + * 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */ +export function centerDirectionOf( + apex: [number, number], + start: [number, number], + end: [number, number], +): [number, number] | null { + const arm = (point: [number, number]): [number, number] => { + const dx = point[0] - apex[0]; + const dy = point[1] - apex[1]; + const length = Math.hypot(dx, dy); + return length <= 1e-9 ? [0, 0] : [dx / length, dy / length]; + }; + const [ux, uy] = arm(start); + const [vx, vy] = arm(end); + const sx = ux + vx; + const sy = uy + vy; + const length = Math.hypot(sx, sy); + return length <= 1e-9 ? null : [sx / length, sy / length]; +} + +/** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */ +const GAP_PX = 40; +/** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */ +const COMPASS_STEPS = 16; + +export interface CurveLabelState { + /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ + seat: number; + /** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */ + at: [number, number]; + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ + centerDirection: [number, number] | null; + curveOn: boolean; + radiusShown: number | null; + /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + arcLengthShown: number | null; + lock: CurveLock; + innerAngleDeg: number | null; +} + +export interface CurveLabelHandlers { + onRadius: (value: number | null) => void; + onArcLength: (value: number | null) => void; + onCurveOn: (on: boolean) => void; + /** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */ + onLock: (lock: CurveLock) => void; +} + +export interface CurveLabel { + show: (state: CurveLabelState) => void; + hide: () => void; + /** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ + destroy: () => void; +} + +/** 방향을 16방위 중 가장 가까운 것으로 맞춘다. */ +function quantize(dx: number, dy: number): [number, number] { + const step = (2 * Math.PI) / COMPASS_STEPS; + const angle = Math.round(Math.atan2(dy, dx) / step) * step; + return [Math.cos(angle), Math.sin(angle)]; +} + +/** 가운데에서 그 방향으로 상자 가장자리까지의 거리 — 방위마다 다르다. */ +function boxReach(dx: number, dy: number, width: number, height: number): number { + const byX = Math.abs(dx) < 1e-9 ? Infinity : width / 2 / Math.abs(dx); + const byY = Math.abs(dy) < 1e-9 ? Infinity : height / 2 / Math.abs(dy); + return Math.min(byX, byY); +} + +/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */ +export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { + const root = document.createElement("div"); + root.className = "b05-routeedit__label"; + root.hidden = true; + root.innerHTML = ` +
+ + +
+ + + + 칸을 비우면 자동`; + document.body.append(root); + + const head = root.querySelector(".b05-routeedit__label-head")!; + const seatText = root.querySelector(".b05-routeedit__curve-label")!; + const toggle = root.querySelector('[data-act="curve-toggle"]')!; + const radius = root.querySelector(".b05-routeedit__curve-radius")!; + const arc = root.querySelector(".b05-routeedit__curve-arc")!; + const lockRadius = root.querySelector('[data-act="lock-radius"]')!; + const lockArc = root.querySelector('[data-act="lock-arc"]')!; + const info = root.querySelector(".b05-routeedit__curve-info")!; + + // 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. + for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { + root.addEventListener(type, (event) => event.stopPropagation()); + } + + let curveOn = true; + let lock: CurveLock = null; + let seat = -1; + /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ + let manual: [number, number] | null = null; + let anchor: [number, number] = [0, 0]; + + const numberOf = (input: HTMLInputElement): number | null => { + const value = Number(input.value); + return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null; + }; + radius.addEventListener("change", () => handlers.onRadius(numberOf(radius))); + arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc))); + toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); + lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); + + // ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ── + let dragFrom: { x: number; y: number; left: number; top: number } | null = null; + head.addEventListener("pointerdown", (event) => { + if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로. + dragFrom = { + x: event.clientX, + y: event.clientY, + left: root.offsetLeft, + top: root.offsetTop, + }; + head.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + head.addEventListener("pointermove", (event) => { + if (!dragFrom) return; + const left = dragFrom.left + event.clientX - dragFrom.x; + const top = dragFrom.top + event.clientY - dragFrom.y; + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + // 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다. + manual = [left - anchor[0], top - anchor[1]]; + }); + const stopDrag = (event: PointerEvent): void => { + if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId); + dragFrom = null; + }; + head.addEventListener("pointerup", stopDrag); + head.addEventListener("pointercancel", stopDrag); + + /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */ + function place(state: CurveLabelState): void { + const width = root.offsetWidth; + const height = root.offsetHeight; + const [nx, ny] = state.at; + const away = state.centerDirection + ? quantize(-state.centerDirection[0], -state.centerDirection[1]) + : ([1, 0] as [number, number]); + const distance = GAP_PX + boxReach(away[0], away[1], width, height); + anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2]; + const left = anchor[0] + (manual ? manual[0] : 0); + const top = anchor[1] + (manual ? manual[1] : 0); + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + } + + return { + show(state) { + if (state.seat !== seat) { + seat = state.seat; + manual = null; // 다른 꺾임점이면 자동 자리부터 다시. + } + curveOn = state.curveOn; + lock = state.lock; + root.hidden = false; + seatText.textContent = `${state.seat + 1}번째 꺾임점`; + toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; + radius.disabled = !state.curveOn; + arc.disabled = !state.curveOn; + lockRadius.disabled = !state.curveOn; + lockArc.disabled = !state.curveOn; + lockRadius.classList.toggle("is-on", lock === "radius"); + lockArc.classList.toggle("is-on", lock === "arc"); + radius.value = + state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); + arc.value = + state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); + const inner = state.innerAngleDeg; + const held = + lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; + info.textContent = state.curveOn + ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + place(state); + // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. + requestAnimationFrame(() => { + if (!root.hidden) place(state); + }); + }, + hide() { + root.hidden = true; + }, + destroy() { + root.remove(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index e0fa8b92..baf289fd 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -34,6 +34,9 @@ export interface SelectionSyncPorts { * 마커·유역 강조가 따라오게 한다(2026-08-17 전역 선택 동기화). 부속 옵션 폼은 * 사이드 「구조물 배치」가 selectSidebar 경로에서 연다. */ selectPipeForm?: (chainageM: number | null) => void; + /** 3D 코리도 구조물 강조(누가거리 기준) — 좌측 폼·종단에서 골라도 3D가 따라온다 + * (2026-09-04). 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다. */ + selectStructure3D?: (chainageM: number | null) => void; } export interface SelectionSync { @@ -77,6 +80,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { try { ports.selectSidebar(station ? station.chainage_m : null); ports.selectPipeForm?.(station && isPipeStation(station) ? station.chainage_m : null); + ports.selectStructure3D?.(station ? station.chainage_m : null); } finally { ports.setSyncing(false); } @@ -104,6 +108,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { // 유역·마커 어느 쪽에서 왔든 시설 폼도 그 관을 연다(투영 측점이 없어도 // 누가거리로 근사 매칭 — Drainage 쪽이 못 찾으면 해제된다). ports.selectPipeForm?.(matched ? matched.chainage_m : chainageM); + ports.selectStructure3D?.(matched ? matched.chainage_m : chainageM); } finally { ports.setSyncing(false); } diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts new file mode 100644 index 00000000..a0214bf3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * B05_Profile_UI_Structure_Pick_Session.ts + * 3D에서 고른 구조물을 **좌측 「구조물 배치」 패널로 넘기고**, 그 선택을 세션 한 칸에 + * 남겨 **B06 진입 때 같은 측점 카드가 열리게** 한다(2026-09-04 사용자 확정). + * + * B05·B06은 라우터가 따로 띄우는 화면이라 실시간 양방향이 아니다 — 조작 상태는 캐시 + * 몫이라는 데이터 3층 규칙대로 sessionStorage 한 칸에 남긴다. 값은 **지우지 않고 남긴다** + * (2026-09-04 사용자: 두 화면이 한 페이지처럼 움직여야 함) — B06에서 고른 것도 같은 칸에 + * 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다. + * ========================================================================== */ + +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"; + +/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍과 같은 모양이다. */ +export interface StructurePickHandoff { + at: number; + key?: string; +} + +/* 넘김값도 ② 설계 초안이다 — 별도 임시 키를 두지 않고 등록표(`b_page_state`)를 쓴다 + (2026-09-06 캐시·세션 일원화). */ + +/** 세션에 남긴다(선택 해제면 지운다). */ +export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void { + if (!projectId) return; + if (!pick) return writeState("structure-pick", null, projectId); + const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key }; + writeState("structure-pick", handoff, projectId); +} + +/** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */ +export function writeStructurePick( + projectId: string | null, + at: number | null, + key?: string, +): void { + if (!projectId) return; + 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 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 { + return null; + } +} + +/** 구간형 구조물이 그 누가거리를 덮는가(기준점형은 ±0.51m 안). */ +function covers(entry: StructureInstance, chainageM: number): boolean { + const start = entry.start_m ?? entry.chainage_m; + const end = entry.end_m ?? entry.chainage_m; + if (start == null || end == null) return false; + return chainageM >= Math.min(start, end) - 0.51 && chainageM <= Math.max(start, end) + 0.51; +} + +/** + * 3D 클릭 결과를 화면 전체에 적용한다. + * + * 배수관 세트(기슭막이·집수정·배관)는 `selectStation`(=기존 선택 동기화)이 좌측 폼·종단 + * 그래프·3D 마커·유역도를 한 줄로 맞춘다. 그 줄에서 아무것도 안 잡히면 옛 정본 구조물로 + * 보고 구조물 id로 연다. + */ +export function wireStructurePick( + controls: StructurePickControls, + projectId: string | null, + structures: StructuresSection, + /** 기존 선택 동기화(`selectStationOfPipe`) — 누가거리 하나로 전 화면을 맞춘다. */ + selectStation: (chainageM: number | null) => void, +): void { + controls.onPick = (pick: StructurePick | null): void => { + rememberStructurePick(projectId, pick); + if (!pick) { + selectStation(null); + return; + } + selectStation(pick.chainageM); + if (structures.hasSelection()) return; + const hit = structures.getStructures().find((entry) => covers(entry, pick.chainageM)); + structures.selectById(hit?.structure_id ?? null); + }; +} + +/** + * 화면에 들어올 때 세션에 남은 선택을 되살린다 — B06에서 고른 것도 그대로 이어 받는다 + * (2026-09-04 사용자: 두 화면이 한 페이지처럼). 3D 강조는 코리도가 뜬 뒤 자동으로 다시 + * 칠해지므로 여기서는 선택만 세워 둔다. + * + * **되살렸으면 true.** 관 목록이 아직 안 실렸으면 폼이 그 시설을 못 찾으므로 false를 + * 돌려준다 — 부르는 쪽은 목록이 실릴 때마다 다시 부르면 된다. + */ +export function restoreStructurePick( + controls: StructurePickControls, + projectId: string | null, + structures: StructuresSection, + selectStation: (chainageM: number | null) => void, +): boolean { + const handoff = readStructurePick(projectId); + if (!handoff) return true; // 되살릴 것이 없으면 끝난 것으로 본다. + controls.select({ chainageM: handoff.at, kind: "", key: handoff.key }); + selectStation(handoff.at); + return structures.hasSelection(); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts new file mode 100644 index 00000000..f09bc1cf --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Form.ts + * 「구조물 배치」 폼의 **뼈대**만 세우는 조립기 (2026-09-03 · 700줄 제한). + * + * 패널 본체(`_UI_Structures_Panel`)는 값 읽기·검증·저장·목록 갱신을 맡고, 여기서는 + * 화면 요소를 만들어 넘기기만 한다. 배치 규칙(행 순서·구분선·버튼 묶음)은 사용자 지시로 + * 굳은 것이라 그대로 옮겼다. + * + * 화면 구성(2026-08-17~29 사용자 지시): + * [구조물군][종류] ← 2열 격자 + * [측점 범위] [시작 측점] [기준 측점] [종료 측점] ← 타입에 따라 보이는 행이 갈린다 + * ──────── 구분선 + * [옵션 격자] / [계곡 통과 시설 서브폼] + * [추가·삭제·리셋] + * 목록(`list`)은 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다. + * ========================================================================== */ + +import { + field, + select, + type StationFields, + stationFields, +} from "./B05_Profile_UI_Structures_Fields"; +import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; + +export interface StructuresFormElements { + root: HTMLElement; + body: HTMLElement; + groupSelect: HTMLSelectElement; + typeSelect: HTMLSelectElement; + /** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */ + ownerNote: HTMLElement; + startFields: StationFields; + anchorFields: StationFields; + endFields: StationFields; + memoField: HTMLInputElement; + /** 계산으로 채우는 측점 범위 표시(직접 입력이 아니다). */ + rangeValue: HTMLElement; + rangeWrap: HTMLElement; + positionRow: HTMLElement; + /** 측점 그룹과 옵션 사이 구분선 — 타입이 없으면 함께 숨긴다. */ + positionDivider: HTMLElement; + optionRow: HTMLElement; + /** 버튼 묶음 — 패널이 그 앞에 메모 칸을 끼워 넣는다. */ + actions: HTMLElement; + facilityOptions: ReturnType; + primary: HTMLButtonElement; + removeButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + /** 하단 고정 영역에 패널이 따로 붙이는 구조물 목록. */ + list: HTMLElement; +} + +/** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */ +export function buildStructuresForm(options: { + /** 측점 잔여거리 정규화에 쓰는 현재 측점 간격(m). */ + getInterval: () => number; + /** 계곡 통과 시설 서브폼 값이 바뀔 때 — 패널이 즉시 반영을 건다. */ + onFacilityChange: () => void; +}): StructuresFormElements { + const root = document.createElement("section"); + // b05-structure-section: 이 섹션 안의 입력·버튼 높이를 한 값(--b05-control-h)으로 + // 묶는 스코프(2026-08-29 사용자 지시 — 조작 컨트롤 높이 통일). + root.className = + "b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section"; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = "구조물 배치"; + const body = document.createElement("div"); + body.className = "b05-route__panel-body"; + root.append(heading, body); + + const groupSelect = select([]); + const typeSelect = select([]); + // 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정). + // 점형은 기준 측점만 보인다. 잔여거리는 [0, 간격) — 넘치면 측점번호로 올려 표시한다. + const startFields = stationFields("시작 측점", options.getInterval); + const anchorFields = stationFields("기준 측점", options.getInterval); + const endFields = stationFields("종료 측점", options.getInterval); + const memoField = document.createElement("input"); + memoField.type = "text"; + memoField.placeholder = "메모(선택)"; + + // 측점 범위(계산 표시) — C군 사면안정처럼 길이 옵션으로 구간이 정해지는 타입은 + // 시작·종료를 직접 입력하지 않고 기준 측점 + 길이로 계산해 보여만 준다 + // (2026-08-19 사용자 지시). 표기는 하단 구조물 목록과 같은 시작 ~ 종료 측점. + const rangeValue = document.createElement("span"); + rangeValue.className = "b05-structure__range-value"; + const rangeWrap = field("측점 범위", rangeValue); + + // 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2). + const positionRow = document.createElement("div"); + positionRow.className = "b05-structure__position-row"; + positionRow.append(rangeWrap, startFields.wrap, anchorFields.wrap, endFields.wrap); + + // 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1). + const typeRow = document.createElement("div"); + 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"; + + // 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은 자리에서 + // 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 사용자 지시 — + // 자동·수동 배관은 같은 구조물, 별도 UI 금지). + const facilityOptions = createFacilityOptionsForm({ onChange: options.onFacilityChange }); + + const primary = document.createElement("button"); + primary.type = "button"; + primary.className = "b05-route__irregular-btn is-primary"; + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "b05-route__irregular-btn is-danger"; + removeButton.textContent = "삭제"; + const resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.className = "b05-route__irregular-btn"; + resetButton.textContent = "리셋"; + const actions = document.createElement("div"); + actions.className = "b05-route__irregular-actions"; + actions.append(primary, removeButton, resetButton); + + // 목록은 이 섹션 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다. + // 패널(B05_Profile_UI_Panel)이 하단 고정 영역에 배치한다(2026-08-18 사용자 지시). + const list = document.createElement("ul"); + list.className = "b05-route__irregular-list"; + + // 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2). + const positionDivider = document.createElement("hr"); + positionDivider.className = "b05-structure__divider"; + + body.append( + typeRow, + ownerNote, + positionRow, + positionDivider, + optionRow, + facilityOptions.root, + actions, + ); + + return { + root, + body, + groupSelect, + typeSelect, + ownerNote, + startFields, + anchorFields, + endFields, + memoField, + rangeValue, + rangeWrap, + positionRow, + positionDivider, + optionRow, + actions, + facilityOptions, + primary, + removeButton, + resetButton, + list, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 9966affb..ea48714c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -103,15 +103,19 @@ export function renderStructureList(params: StructureListParams): void { item.classList.toggle("is-selected", structure.structure_id === editingId); station.textContent = stationOfStructure(structure, intervalM); item.addEventListener("click", () => params.onSelectStructure(structure)); - if (structure.structure_id === editingId) item.scrollIntoView({ block: "nearest" }); + if (structure.structure_id === editingId) { + requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" })); + } } else { const { pipe } = row; - item.classList.toggle( - "is-selected", - selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05, - ); + const picked = + selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; + item.classList.toggle("is-selected", picked); station.textContent = formatStation(pipe.chainage_m, intervalM); item.addEventListener("click", () => params.onSelectPipe(pipe)); + // 하이라이트만으로는 목록 밖에 있으면 안 보인다 — 그 자리로 끌어온다(2026-09-04 + // 사용자). 패널이 막 펼쳐진 참이라 자리가 잡힌 다음 프레임에 민다. + if (picked) requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" })); } item.append(station, name); list.append(item); 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 49a37b3f..fe1ab070 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -18,155 +18,69 @@ import { isB05Option, structureAnchorM, type StructureInstance, + type StructuresSectionOptions, type StructurePlacement, type StructureType, } from "./B05_Profile_Api_Structures"; -import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; +import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields"; +import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { - createFacilityOptionsForm, - type FacilityAttributes, - type FacilityOptionsForm, -} from "./B05_Profile_UI_Drainage_Facility"; -import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields"; + bindStructuresEvents, + type StructuresEventContext, +} from "./B05_Profile_UI_Structures_Panel_Events"; +import { + commit as commitInto, + emit as emitFrom, + loadForm as loadFormInto, + loadPipeForm as loadPipeFormInto, +} from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { formatStation } from "./B05_Profile_Util_Station"; -/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. - * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ -export const GROUP_LABELS: Record = { - A: "A 횡단배수", - B: "B 종단배수", - C: "C 사면안정", - D: "D 계류·사방", - E: "E 안전·부대·용지", - F: "F 생태·녹화", - G: "G 노면공", - 호환: "기타", -}; +/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — + * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ +export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; +export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; +import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; +import type { + PipeFacilityItem, + StructuresCallbacks, + StructuresSection, +} from "./B05_Profile_UI_Structures_Panel_Types"; -/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ -const DEFAULT_INTERVAL_LENGTH_M = 15; - -/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */ -export interface PipeFacilityItem { - chainage_m: number; - facility: PipeFacility; - start_m?: number; - end_m?: number; - /** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */ - source?: PipeSource; - /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */ - design_flow_m3s?: number | null; - /** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */ - options?: Record; -} - -export interface StructuresSection { - root: HTMLElement; - /** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구 - * 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */ - facility: FacilityOptionsForm; - /** 배치된 구조물 목록(
    ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 - * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ - listRoot: HTMLElement; - /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ - setTypes: (types: StructureType[]) => void; - /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ - setStructures: (structures: StructureInstance[]) => void; - getStructures: () => StructureInstance[]; - /** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */ - setPipeFacilities: (pipes: PipeFacilityItem[]) => void; - /** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */ - selectPipeByChainage: (chainageM: number | null) => void; - /** 지금 폼이 **어떤 항목을 고쳐 쓰는 중**인가 — 선택이 없으면 폼 값은 아무 데도 - * 가지 않는다(B06에서 그 상태를 안내하는 데 쓴다, 2026-08-30). */ - hasSelection: () => boolean; - /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ - selectById: (structureId: string | null) => void; - /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ - addAt: (chainageM: number, typeId: string) => void; - /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ - moveById: (structureId: string, toChainageM: number) => boolean; - removeById: (structureId: string) => boolean; -} - -interface StructuresCallbacks { - /** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */ - onChange: (structures: StructureInstance[]) => void; - /** 목록에서 고르거나 해제할 때. */ - onSelect: (structure: StructureInstance | null) => void; - /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ - getInterval: () => number; - /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). - * attributes에 시설 종류·구간·부속 옵션이 담긴다. */ - onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void; - /** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */ - onPipeUpdate: ( - fromChainageM: number, - toChainageM: number, - attributes: FacilityAttributes, - ) => void; - /** 계곡 통과 시설 삭제. */ - onPipeRemove: (chainageM: number) => void; - /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). - * null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */ - onPipeSelect: (chainageM: number | null) => void; -} - -export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { - const root = document.createElement("section"); - // b05-structure-section: 이 섹션 안의 입력·버튼 높이를 한 값(--b05-control-h)으로 - // 묶는 스코프(2026-08-29 사용자 지시 — 조작 컨트롤 높이 통일). - root.className = - "b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section"; - const heading = document.createElement("h3"); - heading.className = "ui-collapsible__title"; - heading.textContent = "구조물 배치"; - const body = document.createElement("div"); - body.className = "b05-route__panel-body"; - root.append(heading, body); - - const groupSelect = select([]); - const typeSelect = select([]); - // 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정). - // 점형은 기준 측점만 보인다. - // 측점 표기 전역 규칙(2026-08-18): 잔여거리는 [0, 간격) — 넘치면 측점번호로 - // 올려 표시한다(1+30 → 2+10). 정규화는 stationFields가 입력 확정 때 처리. - const startFields = stationFields("시작 측점", () => callbacks.getInterval()); - const anchorFields = stationFields("기준 측점", () => callbacks.getInterval()); - const endFields = stationFields("종료 측점", () => callbacks.getInterval()); - const memoField = document.createElement("input"); - memoField.type = "text"; - memoField.placeholder = "메모(선택)"; - - // 측점 범위(계산 표시) — C군 사면안정처럼 길이 옵션으로 구간이 정해지는 타입은 - // 시작·종료를 직접 입력하지 않고 기준 측점 + 길이로 계산해 보여만 준다 - // (2026-08-19 사용자 지시). 표기는 하단 구조물 목록과 같은 시작 ~ 종료 측점. - const rangeValue = document.createElement("span"); - rangeValue.className = "b05-structure__range-value"; - const rangeWrap = field("측점 범위", rangeValue); - - // 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2). - // 범위 계산 타입은 [측점 범위][기준 측점]만 보인다(범위 행이 기준 측점 위). - const positionRow = document.createElement("div"); - positionRow.className = "b05-structure__position-row"; - positionRow.append(rangeWrap, startFields.wrap, anchorFields.wrap, endFields.wrap); - - // 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1). - const typeRow = document.createElement("div"); - typeRow.className = "b05-structure__grid"; - typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); - - // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. - const optionRow = document.createElement("div"); - optionRow.className = "b05-structure__grid"; - - // 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은 - // 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 - // 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를 - // 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다. - const facilityOptions = createFacilityOptionsForm({ onChange: () => liveCommit() }); +export function createStructuresSection( + callbacks: StructuresCallbacks, + sectionOptions: StructuresSectionOptions = {}, +): StructuresSection { + // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. + const form = buildStructuresForm({ + getInterval: () => callbacks.getInterval(), + onFacilityChange: () => liveCommit(), + }); + const { + root, + body, + groupSelect, + typeSelect, + ownerNote, + startFields, + anchorFields, + endFields, + memoField, + rangeValue, + rangeWrap, + positionRow, + positionDivider, + optionRow, + actions, + facilityOptions, + primary, + removeButton, + resetButton, + list, + } = form; /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 @@ -248,36 +162,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu : "—"; } - const primary = document.createElement("button"); - primary.type = "button"; - primary.className = "b05-route__irregular-btn is-primary"; - const removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "b05-route__irregular-btn is-danger"; - removeButton.textContent = "삭제"; - const resetButton = document.createElement("button"); - resetButton.type = "button"; - resetButton.className = "b05-route__irregular-btn"; - resetButton.textContent = "리셋"; - const actions = document.createElement("div"); - actions.className = "b05-route__irregular-actions"; - actions.append(primary, removeButton, resetButton); - - // 목록은 이 섹션 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다. - // 패널(B05_Profile_UI_Panel)이 하단 고정 영역에 배치한다(2026-08-18 사용자 지시). - const list = document.createElement("ul"); - list.className = "b05-route__irregular-list"; - // 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시, - // 필요 시 별도 설명 페이지로). - - // 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2). - const positionDivider = document.createElement("hr"); - positionDivider.className = "b05-structure__divider"; - - body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions); - let types: StructureType[] = []; - let structures: StructureInstance[] = []; + // 배열 자체를 바꾸지 않는다(조각들이 참조로 들고 있음) — 내용만 갈아끼운다. + const structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; let editingId: string | null = null; /** 목록에서 고른 계곡 통과 시설(누가거리 키). 삭제 버튼이 이쪽으로 동작한다. */ @@ -290,7 +177,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu * 순간(또는 폼에 위치를 실어 연 순간)에만 true. 옵션 활성화 시점도 임시 배치와 * 같은 이 순간이다(2026-08-18 사용자 지시). */ let positionConfirmed = false; - let optionInputs: Array<{ + const optionInputs: Array<{ key: string; required: boolean; input: HTMLInputElement | HTMLSelectElement; @@ -326,6 +213,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 ? "기준 측점 (비우면 시작)" : "기준 측점"; @@ -405,14 +298,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu syncRangeDisplay(); } - /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 - * 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */ + /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면 + * (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */ function renderOptionFields(values: Record = {}): void { const type = currentType(); - optionInputs = []; + // 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로 + // 바꾸면 옛 칸 목록을 읽어 저장값이 어긋난다(2026-09-04). + optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. - const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : []; + const all = sectionOptions.includeDetail === true; + const visible = + type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : []; if (!visible.length) { optionRow.hidden = true; syncOptionLock(); @@ -426,8 +323,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu // 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은 // 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1). const choices = option.choices.map((choice) => [choice, choice] as [string, string]); + // 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다. + const mustPick = option.required === true && (option.default ?? "") === ""; + if (mustPick) choices.unshift(["", "— 선택 —"]); input = select(choices); - input.value = String(preset || (option.choices[0] ?? "")); + input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? ""))); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); @@ -438,11 +338,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() === "", @@ -568,361 +475,71 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }); } - function loadForm(target: StructureInstance | null): void { - // 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다. - cancelTempPipe(); - editingId = target?.structure_id ?? null; - selectedPipeChainage = null; - // 위치가 실려 열리는 폼(기존 항목)은 확정 상태, 빈 폼은 미확정으로 시작한다. - positionConfirmed = target !== null; - const step = interval(); - if (target) { - const type = typeMap().get(target.type_id); - if (type) { - groupSelect.value = type.group; - syncTypeOptions(target.type_id); - } - anchorFields.write(structureAnchorM(target), step); - startFields.write(target.start_m ?? null, step); - endFields.write(target.end_m ?? null, step); - memoField.value = target.memo ?? ""; - renderOptionFields(target.options); - // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 - // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). - if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { - const lengthEntry = optionInputs.find((entry) => entry.key === "length_m"); - if (lengthEntry && lengthEntry.isEmpty()) { - lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1); - } - const beforeEntry = optionInputs.find((entry) => entry.key === "before_m"); - if (beforeEntry && target.options.before_m === undefined) { - // 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다 - // — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다. - const before = Math.max(structureAnchorM(target) - target.start_m, 0); - beforeEntry.input.value = before.toFixed(1); - } - const afterEntry = optionInputs.find((entry) => entry.key === "after_m"); - if (afterEntry && target.options.after_m === undefined) { - const after = Math.max(target.end_m - structureAnchorM(target), 0); - afterEntry.input.value = after.toFixed(1); - } - } - syncFacilityForm(target.options); - } else { - anchorFields.write(null, step); - startFields.write(null, step); - endFields.write(null, step); - memoField.value = ""; - renderOptionFields(); - syncFacilityForm(); - } - [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); - syncPlacementFields(); - syncButtons(); - renderList(); - callbacks.onSelect(target); - } + /* 폼 ↔ 정본 왕복(불러오기·저장)은 700줄 제한으로 `_Structures_Panel_Commit` 로 옮겼다. */ + const loadForm = (target: StructureInstance | null): void => loadFormInto(panelContext, target); + const loadPipeForm = (pipe: PipeFacilityItem): void => loadPipeFormInto(panelContext, pipe); + const emit = (): void => emitFrom(panelContext); + const commit = (live = false): Promise => commitInto(panelContext, live); - /** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). - * 정본이 관 지점이라 editingId 대신 선택 누가거리로 추적한다. */ - function loadPipeForm(pipe: PipeFacilityItem): void { - // 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우 — 사용자가 폼에 입력하는 - // 중이므로 폼을 다시 그리지 않는다(입력이 지워진다, 2026-08-18 사용자 보고). - // 재계산으로 갱신된 설계유량만 살짝 반영하고 선택 표시를 맞춘다. - // 관은 계획선에 스냅되며 입력 누가거리와 살짝 어긋날 수 있다 — 0.51m까지 같은 - // 관으로 본다(다른 곳의 관 매칭 기준과 동일). - const isTemp = tempPipeChainage !== null && Math.abs(pipe.chainage_m - tempPipeChainage) < 0.51; - if (isTemp) { - tempPipeChainage = pipe.chainage_m; - selectedPipeChainage = pipe.chainage_m; - facilityOptions.setDesignFlow(pipe.design_flow_m3s ?? null); - syncButtons(); - renderList(); - return; - } - // 다른 항목으로 넘어가는 것이므로 임시 배치를 취소한다. - cancelTempPipe(); - const hadStructure = editingId !== null; - editingId = null; - selectedPipeChainage = pipe.chainage_m; - positionConfirmed = true; // 기존 관 — 위치가 실려 열린다. - const step = interval(); - const type = typeMap().get(pipe.facility); - if (type) { - groupSelect.value = type.group; - syncTypeOptions(pipe.facility); - } - anchorFields.write(pipe.chainage_m, step); - // 계곡 통과 시설은 점형 — 시작·종료 칸은 감춰지므로 비워 둔다. - startFields.write(null, step); - endFields.write(null, step); - memoField.value = ""; - syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); - [anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid()); - syncPlacementFields(); - syncButtons(); - renderList(); - if (hadStructure) callbacks.onSelect(null); - } - - function readOptions(): Record { - // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. - if (facilityFormKind(currentType())) return facilityOptions.readOptions(); - const values: Record = {}; - optionInputs.forEach((input) => { - // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. - if (input.isEmpty()) return; - values[input.key] = input.read(); - }); - return values; - } - - function emit(): void { - renderList(); - callbacks.onChange([...structures]); - } - - async function commit(live = false): Promise { - const type = currentType(); - if (!type) return; - const step = interval(); - - // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 - // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. - if (type.managed_by) { - const anchor = anchorFields.read(step, true); - if (anchor === null) { - anchorFields.station.focus(); - return; - } - // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). - const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; - const options = facilityOptions.readOptions(); - if (Object.keys(options).length) attributes.options = options; - if (tempPipeChainage !== null) { - // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. - const from = tempPipeChainage; - tempPipeChainage = null; - callbacks.onPipeUpdate(from, anchor, attributes); - } else if (selectedPipeChainage !== null) { - callbacks.onPipeUpdate(selectedPipeChainage, anchor, attributes); - } else { - callbacks.onPipeAdd(anchor, attributes); - } - // 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다. - if (!live) loadForm(null); - return; - } - - const placement = type.placement; - let anchor: number | null; - let start: number | null = null; - let end: number | null = null; - if (placement === "interval" && hasComputedRange(type)) { - // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 - // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. - anchor = anchorFields.read(step, true); - if (anchor === null) { - anchorFields.station.focus(); - return; - } - const length = readLengthM(); - if (length === null) { - optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); - return; - } - const before = readBeforeM(); - if (anchor - before < -0.005) { - const beforeInput = optionInputs.find((entry) => entry.key === "before_m")?.input; - beforeInput?.classList.add("is-invalid"); - beforeInput?.focus(); - return; - } - start = Math.max(anchor - before, 0); - end = start + length; - } else if (placement === "interval") { - start = startFields.read(step, true); - end = endFields.read(step, true); - if (start === null || end === null || end <= start) { - (start === null ? startFields.station : endFields.station).focus(); - return; - } - // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. - anchor = anchorFields.read(step, false) ?? start; - if (anchor < start || anchor > end) { - anchorFields.station.classList.add("is-invalid"); - anchorFields.station.focus(); - return; - } - } else { - anchor = anchorFields.read(step, true); - if (anchor === null) { - anchorFields.station.focus(); - return; - } - } - // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. - // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). - const missing = optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing) { - missing.input.focus(); - return; - } - - const base = { - type_id: type.type_id, - placement, - chainage_m: anchor, - start_m: start, - end_m: end, - options: readOptions(), - memo: memoField.value.trim(), - placement_source: "manual" as const, - status: "draft" as const, - revision: 0, - geometry: null, - }; - - // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 - // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. - const spans = - type.type_id === "pavement_concrete" && start !== null && end !== null - ? await splitPavementRange(start, end, pipeFacilities) - : [{ start, end }]; - if (!spans) return; - const records = spans.map((span) => ({ - ...base, - start_m: span.start, - end_m: span.end, - chainage_m: - span.start === null || span.end === null - ? anchor - : Math.min(Math.max(anchor as number, span.start), span.end), - })); - - if (editingId) { - const index = structures.findIndex((entry) => entry.structure_id === editingId); - if (index >= 0) structures[index] = { ...structures[index], ...records[0] }; - records.slice(1).forEach((record) => structures.push({ ...record, structure_id: null })); - } else { - records.forEach((record) => structures.push({ ...record, structure_id: null })); - } - if (!live) loadForm(null); - emit(); - } - - groupSelect.addEventListener("change", () => { - cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). - syncTypeOptions(); - // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). - if (editingId) { - editingId = null; - syncButtons(); - renderList(); - callbacks.onSelect(null); - } - }); - typeSelect.addEventListener("change", () => { - cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). - syncPlacementFields(); - renderOptionFields(); - // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — - // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: - // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). - syncFacilityForm(); - if (editingId) { - editingId = null; - syncButtons(); - renderList(); - callbacks.onSelect(null); - } - }); - primary.addEventListener("click", () => void commit()); - removeButton.addEventListener("click", () => { - // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). - if (tempPipeChainage !== null) { - resetForm(); - return; - } - // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). - if (selectedPipeChainage !== null) { - callbacks.onPipeRemove(selectedPipeChainage); - selectedPipeChainage = null; - syncButtons(); - renderList(); - return; - } - if (!editingId) return; - const index = structures.findIndex((entry) => entry.structure_id === editingId); - if (index >= 0) structures.splice(index, 1); - loadForm(null); - emit(); - }); - // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). - // 임시 배치 취소도 겸한다(2026-08-18). - resetButton.addEventListener("click", resetForm); - // 측점 입력 감시 — 옵션 잠금 해제 판정(2026-08-18). - [anchorFields, startFields, endFields].forEach((fields) => - [fields.station, fields.remainder].forEach((input) => - input.addEventListener("change", handleStationInput), - ), - ); - // 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다 - // (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면 - // 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시. - anchorFields.remainder.addEventListener("change", () => commitPosition()); - anchorFields.station.addEventListener("change", () => { - if (anchorFields.remainder.value.trim() !== "") commitPosition(); - }); - // 그 외(잔여거리를 안 쓰는 정측점 입력 등)는 입력군에서 포커스가 완전히 빠져나간 - // 뒤에 확정한다 — 측점번호만 넣고 잔여거리 칸으로 옮기는 중간에는 나가지 않는다. - // Enter로도 바로 나갈 수 있다. - positionRow.addEventListener("focusout", () => { - // 다음 포커스 대상이 확정된 다음 프레임에 판단한다(relatedTarget은 브라우저에 - // 따라 비어 온다). - window.requestAnimationFrame(() => { - if (positionRow.contains(document.activeElement)) return; - commitPosition(); - }); - }); - /** 지금 보이는 위치 입력 칸들(화면 순서). Tab 흐름 판정에 쓴다. */ - function visiblePositionInputs(): HTMLInputElement[] { - return [startFields, anchorFields, endFields] - .filter((fields) => !fields.wrap.hidden) - .flatMap((fields) => [fields.station, fields.remainder]); - } - - /** 상세 옵션의 첫 입력 칸으로 포커스 — 없으면 메모로. */ - function focusFirstDetailInput(): void { - const target = - optionInputs.find((entry) => !entry.input.disabled && !optionRow.hidden)?.input ?? - facilityOptions.root.querySelector( - "input:not(:disabled), select:not(:disabled)", - ) ?? - memoField; - target.focus(); - } - - positionRow.addEventListener("keydown", (event) => { - // Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+ - // 잔여거리 합산값이 그대로 임시 배치 좌표가 된다). - if (event.key === "Enter" && document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - // Tab = 화면 순서(위→아래)대로 다음 입력인 상세 옵션 첫 칸으로. 위치 확정으로 - // 옵션 잠금이 풀리는 시점이 브라우저 기본 탭 계산보다 늦어, disabled 상태를 본 - // 탭이 메모까지 건너뛰던 문제(2026-08-19 사용자 보고). - if (event.key === "Tab" && !event.shiftKey) { - const inputs = visiblePositionInputs(); - if (document.activeElement === inputs[inputs.length - 1]) { - event.preventDefault(); - (document.activeElement as HTMLElement).blur(); // change → 위치 확정 → 잠금 해제 - window.requestAnimationFrame(focusFirstDetailInput); - } - } - }); - - memoField.addEventListener("change", () => liveCommit()); + /* 입력 배선은 700줄 제한으로 `_Structures_Panel_Events` 로 옮겼다 — 처리 함수는 여기 그대로다. */ + /* 저장·배선이 함께 보는 패널 컨텍스트 — 가변 상태의 주인은 그대로 패널이다. */ + const panelContext: StructuresEventContext = { + groupSelect, + typeSelect, + primary, + removeButton, + resetButton, + positionRow, + memoField, + anchorFields, + startFields, + endFields, + facilityOptions, + callbacks, + structures, + types: () => types, + typeMap, + currentType, + interval, + commit, + resetForm: () => resetForm(), + handleStationInput: () => handleStationInput(), + commitPosition: () => commitPosition(), + liveCommit: () => liveCommit(), + loadForm, + cancelTempPipe: () => cancelTempPipe(), + syncTypeOptions: (keepTypeId) => syncTypeOptions(keepTypeId), + syncPlacementFields: () => syncPlacementFields(), + syncButtons: () => syncButtons(), + renderList: () => renderList(), + renderOptionFields: (values) => renderOptionFields(values), + emit, + optionRow, + optionInputs, + pipeFacilities: () => pipeFacilities, + facilityFormKind, + hasComputedRange, + readLengthM, + readBeforeM, + syncFacilityForm: (options, designFlow) => syncFacilityForm(options, designFlow), + editingId: () => editingId, + setEditingId: (value) => { + editingId = value; + }, + selectedPipeChainage: () => selectedPipeChainage, + setSelectedPipeChainage: (value) => { + selectedPipeChainage = value; + }, + tempPipeChainage: () => tempPipeChainage, + setTempPipeChainage: (value) => { + tempPipeChainage = value; + }, + positionConfirmed: () => positionConfirmed, + setPositionConfirmed: (value) => { + positionConfirmed = value; + }, + }; + bindStructuresEvents(panelContext); body.insertBefore(field("메모", memoField), actions); syncButtons(); renderList(); @@ -946,7 +563,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu setStructures(next) { // 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어 // 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만). - structures = next.map((entry) => ({ ...entry })); + // 배열을 **갈아끼우지 않고 안을 채운다** — 저장·배선 조각(`_Commit`·`_Events`)이 + // 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐 + // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). + structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. if (applyingLive) renderList(); else { @@ -987,6 +607,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu renderList(); }, selectPipeByChainage(chainageM) { + if (chainageM !== null) callbacks.onReveal?.(); if (chainageM === null) { // 외부 해제(빈 공간 클릭 등) — 임시 배치는 취소, 선택 중이던 시설도 구조물군· // 종류까지 완전 리셋(2026-08-18 사용자 지시). 아무것도 안 골랐으면 무시. @@ -1000,6 +621,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu loadPipeForm(pipe); }, selectById(structureId) { + if (structureId !== null) callbacks.onReveal?.(); if (structureId === null) { // 그래프·3D 빈 공간 클릭 등 외부 해제 — 선택 중이었으면 구조물군·종류까지 // 완전 리셋(2026-08-18 사용자 지시). 선택 없는 신규 작성 중에는 두지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts new file mode 100644 index 00000000..f9fd343e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -0,0 +1,312 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Commit.ts + * 구조물 배치 패널의 **폼 ↔ 정본 왕복** — 폼 불러오기(구조물·계곡 통과 시설)와 + * [추가]/[수정] 저장, 목록 알림. + * + * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 검증 순서·값·분기는 옮기기 전 그대로이고, 패널 클로저가 쥐던 값만 `ctx` 로 받는다 + * (가변 상태 4개는 읽기 함수 + set* 로 넘긴다 — 주인은 그대로 패널이다). + * ========================================================================== */ + +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; +import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; +import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; +import type { + PipeFacilityItem, + StructuresCallbacks, +} from "./B05_Profile_UI_Structures_Panel_Types"; + +/** 패널이 쥔 값 중 폼 왕복·저장에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ +export interface StructuresCommitContext { + callbacks: StructuresCallbacks; + structures: StructureInstance[]; + groupSelect: HTMLSelectElement; + memoField: HTMLInputElement; + anchorFields: StationFields; + startFields: StationFields; + endFields: StationFields; + facilityOptions: FacilityOptionsForm; + optionInputs: Array<{ + key: string; + required: boolean; + input: HTMLInputElement | HTMLSelectElement; + read: () => string | number; + isEmpty: () => boolean; + }>; + /** 계곡 통과 시설 목록 — 포장 구간이 물넘이와 겹치는지 판정할 때 쓴다. */ + pipeFacilities: () => PipeFacilityItem[]; + interval: () => number; + typeMap: () => Map; + currentType: () => StructureType | null; + facilityFormKind: (type: StructureType | null) => PipeFacility | null; + hasComputedRange: (type: StructureType | null) => boolean; + readLengthM: () => number | null; + readBeforeM: () => number; + cancelTempPipe: () => void; + renderList: () => void; + renderOptionFields: (values?: Record) => void; + syncButtons: () => void; + syncFacilityForm: (options?: Record, designFlow?: number | null) => void; + syncPlacementFields: () => void; + syncTypeOptions: (keepTypeId?: string) => void; + /* 가변 상태 — 주인은 패널이다. */ + editingId: () => string | null; + setEditingId: (value: string | null) => void; + selectedPipeChainage: () => number | null; + setSelectedPipeChainage: (value: number | null) => void; + positionConfirmed: () => boolean; + setPositionConfirmed: (value: boolean) => void; + tempPipeChainage: () => number | null; + setTempPipeChainage: (value: number | null) => void; +} + +export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void { + // 다른 항목으로 넘어가면 임시 배치(A군 미리보기)는 취소된다. + ctx.cancelTempPipe(); + ctx.setEditingId(target?.structure_id ?? null); + ctx.setSelectedPipeChainage(null); + // 위치가 실려 열리는 폼(기존 항목)은 확정 상태, 빈 폼은 미확정으로 시작한다. + ctx.setPositionConfirmed(target !== null); + const step = ctx.interval(); + if (target) { + const type = ctx.typeMap().get(target.type_id); + if (type) { + ctx.groupSelect.value = type.group; + ctx.syncTypeOptions(target.type_id); + } + ctx.anchorFields.write(structureAnchorM(target), step); + ctx.startFields.write(target.start_m ?? null, step); + ctx.endFields.write(target.end_m ?? null, step); + ctx.memoField.value = target.memo ?? ""; + ctx.renderOptionFields(target.options); + // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 + // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). + if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { + const lengthEntry = ctx.optionInputs.find((entry) => entry.key === "length_m"); + if (lengthEntry && lengthEntry.isEmpty()) { + lengthEntry.input.value = (target.end_m - target.start_m).toFixed(1); + } + const beforeEntry = ctx.optionInputs.find((entry) => entry.key === "before_m"); + if (beforeEntry && target.options.before_m === undefined) { + // 옵션이 없던 시절 항목은 기본값(5)이 아니라 저장된 기준−시작을 그대로 쓴다 + // — 0이어도 명시해야 기본값이 범위를 옆으로 밀지 않는다. + const before = Math.max(structureAnchorM(target) - target.start_m, 0); + beforeEntry.input.value = before.toFixed(1); + } + const afterEntry = ctx.optionInputs.find((entry) => entry.key === "after_m"); + if (afterEntry && target.options.after_m === undefined) { + const after = Math.max(target.end_m - structureAnchorM(target), 0); + afterEntry.input.value = after.toFixed(1); + } + } + ctx.syncFacilityForm(target.options); + } else { + ctx.anchorFields.write(null, step); + ctx.startFields.write(null, step); + ctx.endFields.write(null, step); + ctx.memoField.value = ""; + ctx.renderOptionFields(); + ctx.syncFacilityForm(); + } + [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); + ctx.syncPlacementFields(); + ctx.syncButtons(); + ctx.renderList(); + ctx.callbacks.onSelect(target); +} + +/** 계곡 통과 시설을 폼에 올린다 — 일반 구조물과 같은 흐름(종류·측점·옵션·수정). + * 정본이 관 지점이라 ctx.editingId() 대신 선택 누가거리로 추적한다. */ +export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityItem): void { + // 임시 배치 중인 그 관이 재계산을 거쳐 되돌아온 경우 — 사용자가 폼에 입력하는 + // 중이므로 폼을 다시 그리지 않는다(입력이 지워진다, 2026-08-18 사용자 보고). + // 재계산으로 갱신된 설계유량만 살짝 반영하고 선택 표시를 맞춘다. + // 관은 계획선에 스냅되며 입력 누가거리와 살짝 어긋날 수 있다 — 0.51m까지 같은 + // 관으로 본다(다른 곳의 관 매칭 기준과 동일). + const isTemp = + ctx.tempPipeChainage() !== null && + Math.abs(pipe.chainage_m - (ctx.tempPipeChainage() as number)) < 0.51; + if (isTemp) { + ctx.setTempPipeChainage(pipe.chainage_m); + ctx.setSelectedPipeChainage(pipe.chainage_m); + ctx.facilityOptions.setDesignFlow(pipe.design_flow_m3s ?? null); + ctx.syncButtons(); + ctx.renderList(); + return; + } + // 다른 항목으로 넘어가는 것이므로 임시 배치를 취소한다. + ctx.cancelTempPipe(); + const hadStructure = ctx.editingId() !== null; + ctx.setEditingId(null); + ctx.setSelectedPipeChainage(pipe.chainage_m); + ctx.setPositionConfirmed(true); // 기존 관 — 위치가 실려 열린다. + const step = ctx.interval(); + const type = ctx.typeMap().get(pipe.facility); + if (type) { + ctx.groupSelect.value = type.group; + ctx.syncTypeOptions(pipe.facility); + } + ctx.anchorFields.write(pipe.chainage_m, step); + // 계곡 통과 시설은 점형 — 시작·종료 칸은 감춰지므로 비워 둔다. + ctx.startFields.write(null, step); + ctx.endFields.write(null, step); + ctx.memoField.value = ""; + ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); + [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); + ctx.syncPlacementFields(); + ctx.syncButtons(); + ctx.renderList(); + if (hadStructure) ctx.callbacks.onSelect(null); +} + +export function readOptions(ctx: StructuresCommitContext): Record { + // 서브폼이 담당하는 타입(독립 기슭막이)은 서브폼이 값을 읽는다. + if (ctx.facilityFormKind(ctx.currentType())) return ctx.facilityOptions.readOptions(); + const values: Record = {}; + ctx.optionInputs.forEach((input) => { + // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. + if (input.isEmpty()) return; + values[input.key] = input.read(); + }); + return values; +} + +export function emit(ctx: StructuresCommitContext): void { + ctx.renderList(); + ctx.callbacks.onChange([...ctx.structures]); +} + +export async function commit(ctx: StructuresCommitContext, live = false): Promise { + const type = ctx.currentType(); + if (!type) return; + const step = ctx.interval(); + + // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 + // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. + if (type.managed_by) { + const anchor = ctx.anchorFields.read(step, true); + if (anchor === null) { + ctx.anchorFields.station.focus(); + return; + } + // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). + const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; + const options = ctx.facilityOptions.readOptions(); + if (Object.keys(options).length) attributes.options = options; + if (ctx.tempPipeChainage() !== null) { + // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. + const from = ctx.tempPipeChainage() as number; + ctx.setTempPipeChainage(null); + ctx.callbacks.onPipeUpdate(from, anchor, attributes); + } else if (ctx.selectedPipeChainage() !== null) { + ctx.callbacks.onPipeUpdate(ctx.selectedPipeChainage() as number, anchor, attributes); + } else { + ctx.callbacks.onPipeAdd(anchor, attributes); + } + // 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다. + if (!live) loadForm(ctx, null); + return; + } + + const placement = type.placement; + let anchor: number | null; + let start: number | null = null; + let end: number | null = null; + if (placement === "interval" && ctx.hasComputedRange(type)) { + // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 + // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. + anchor = ctx.anchorFields.read(step, true); + if (anchor === null) { + ctx.anchorFields.station.focus(); + return; + } + const length = ctx.readLengthM(); + if (length === null) { + ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); + return; + } + const before = ctx.readBeforeM(); + if (anchor - before < -0.005) { + const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; + beforeInput?.classList.add("is-invalid"); + beforeInput?.focus(); + return; + } + start = Math.max(anchor - before, 0); + end = start + length; + } else if (placement === "interval") { + start = ctx.startFields.read(step, true); + end = ctx.endFields.read(step, true); + if (start === null || end === null || end <= start) { + (start === null ? ctx.startFields.station : ctx.endFields.station).focus(); + return; + } + // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. + anchor = ctx.anchorFields.read(step, false) ?? start; + if (anchor < start || anchor > end) { + ctx.anchorFields.station.classList.add("is-invalid"); + ctx.anchorFields.station.focus(); + return; + } + } else { + anchor = ctx.anchorFields.read(step, true); + if (anchor === null) { + ctx.anchorFields.station.focus(); + return; + } + } + // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. + // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). + const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); + if (missing) { + missing.input.focus(); + return; + } + + const base = { + type_id: type.type_id, + placement, + chainage_m: anchor, + start_m: start, + end_m: end, + options: readOptions(ctx), + memo: ctx.memoField.value.trim(), + placement_source: "manual" as const, + status: "draft" as const, + revision: 0, + geometry: null, + }; + + // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 + // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. + const spans = + type.type_id === "pavement_concrete" && start !== null && end !== null + ? await splitPavementRange(start, end, ctx.pipeFacilities()) + : [{ start, end }]; + if (!spans) return; + const records = spans.map((span) => ({ + ...base, + start_m: span.start, + end_m: span.end, + chainage_m: + span.start === null || span.end === null + ? anchor + : Math.min(Math.max(anchor as number, span.start), span.end), + })); + + if (ctx.editingId()) { + const index = ctx.structures.findIndex((entry) => entry.structure_id === ctx.editingId()); + if (index >= 0) ctx.structures[index] = { ...ctx.structures[index], ...records[0] }; + records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null })); + } else { + records.forEach((record) => ctx.structures.push({ ...record, structure_id: null })); + } + if (!live) loadForm(ctx, null); + emit(ctx); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts new file mode 100644 index 00000000..163e45ed --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Events.ts + * 구조물 배치 패널의 **입력 배선** — 구조물군·종류 드롭다운, [추가]/[삭제]/[리셋], + * 측점 입력 확정(change·focusout·Enter·Tab), 메모 변경. + * + * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 리스너 본문·순서는 옮기기 전 그대로이고, 패널이 쥔 값만 `ctx` 로 받는다. + * 처리 함수(commit·loadForm 등)는 그대로 패널 소유다 — 여기서는 이어 붙이기만 한다. + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import type { StructuresCommitContext } from "./B05_Profile_UI_Structures_Panel_Commit"; + +/** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ +/** 배선에 필요한 값 — 저장 쪽(`StructuresCommitContext`)과 겹치는 것은 그대로 물려받는다. */ +export interface StructuresEventContext extends StructuresCommitContext { + typeSelect: HTMLSelectElement; + primary: HTMLButtonElement; + removeButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + positionRow: HTMLElement; + types: () => StructureType[]; + commit: (live?: boolean) => Promise; + resetForm: () => void; + handleStationInput: () => void; + commitPosition: () => void; + liveCommit: () => void; + loadForm: (target: StructureInstance | null) => void; + emit: () => void; + optionRow: HTMLElement; +} + +export function bindStructuresEvents(ctx: StructuresEventContext): void { + ctx.groupSelect.addEventListener("change", () => { + ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). + ctx.syncTypeOptions(); + // 종류가 바뀌면 기존 항목 수정이 아니라 새 항목 추가로 넘어간다(옵션 스키마가 달라진다). + if (ctx.editingId()) { + ctx.setEditingId(null); + ctx.syncButtons(); + ctx.renderList(); + ctx.callbacks.onSelect(null); + } + }); + ctx.typeSelect.addEventListener("change", () => { + ctx.cancelTempPipe(); // 종류가 달라진다 — 임시 배치는 취소(2026-08-18). + ctx.syncPlacementFields(); + ctx.renderOptionFields(); + // 배수관·BOX암거·세월교·독립 기슭막이는 부속 옵션 서브폼이 따라 열려야 한다 — + // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: + // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). + ctx.syncFacilityForm(); + if (ctx.editingId()) { + ctx.setEditingId(null); + ctx.syncButtons(); + ctx.renderList(); + ctx.callbacks.onSelect(null); + } + }); + ctx.primary.addEventListener("click", () => void ctx.commit()); + ctx.removeButton.addEventListener("click", () => { + // 임시 배치 중이면 취소와 같다 — 관을 물리고 추가 직전 상태로(2026-08-18). + if (ctx.tempPipeChainage() !== null) { + ctx.resetForm(); + return; + } + // 계곡 통과 시설이 골라져 있으면 관 지점 정본에서 지운다(배수유역 패널 경유). + if (ctx.selectedPipeChainage() !== null) { + ctx.callbacks.onPipeRemove(ctx.selectedPipeChainage() as number); + ctx.setSelectedPipeChainage(null); + ctx.syncButtons(); + ctx.renderList(); + return; + } + if (!ctx.editingId()) return; + const index = ctx.structures.findIndex((entry) => entry.structure_id === ctx.editingId()); + if (index >= 0) ctx.structures.splice(index, 1); + ctx.loadForm(null); + ctx.emit(); + }); + // 리셋은 폼만 아니라 구조물군·종류도 빈칸으로 되돌린다(2026-08-17 사용자 지시). + // 임시 배치 취소도 겸한다(2026-08-18). + ctx.resetButton.addEventListener("click", ctx.resetForm); + // 측점 입력 감시 — 옵션 잠금 해제 판정(2026-08-18). + [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => + [fields.station, fields.remainder].forEach((input) => + input.addEventListener("change", ctx.handleStationInput), + ), + ); + // 측점번호+잔여거리가 **둘 다 확정된 순간** 임시 배치·옵션 활성화가 바로 나간다 + // (2026-08-18 사용자 지시 — 입력군을 떠나기 전이라도). 잔여거리 값이 확정되면 + // 즉시, 측점번호 수정은 잔여거리가 이미 있을 때만 즉시. + ctx.anchorFields.remainder.addEventListener("change", () => ctx.commitPosition()); + ctx.anchorFields.station.addEventListener("change", () => { + if (ctx.anchorFields.remainder.value.trim() !== "") ctx.commitPosition(); + }); + // 그 외(잔여거리를 안 쓰는 정측점 입력 등)는 입력군에서 포커스가 완전히 빠져나간 + // 뒤에 확정한다 — 측점번호만 넣고 잔여거리 칸으로 옮기는 중간에는 나가지 않는다. + // Enter로도 바로 나갈 수 있다. + ctx.positionRow.addEventListener("focusout", () => { + // 다음 포커스 대상이 확정된 다음 프레임에 판단한다(relatedTarget은 브라우저에 + // 따라 비어 온다). + window.requestAnimationFrame(() => { + if (ctx.positionRow.contains(document.activeElement)) return; + ctx.commitPosition(); + }); + }); + /** 지금 보이는 위치 입력 칸들(화면 순서). Tab 흐름 판정에 쓴다. */ + function visiblePositionInputs(): HTMLInputElement[] { + return [ctx.startFields, ctx.anchorFields, ctx.endFields] + .filter((fields) => !fields.wrap.hidden) + .flatMap((fields) => [fields.station, fields.remainder]); + } + + /** 상세 옵션의 첫 입력 칸으로 포커스 — 없으면 메모로. */ + function focusFirstDetailInput(): void { + const target = + ctx.optionInputs.find((entry) => !entry.input.disabled && !ctx.optionRow.hidden)?.input ?? + ctx.facilityOptions.root.querySelector( + "input:not(:disabled), select:not(:disabled)", + ) ?? + ctx.memoField; + target.focus(); + } + + ctx.positionRow.addEventListener("keydown", (event) => { + // Enter = 입력 확정 — 포커스를 빼서 focusout 경로 하나로 처리한다(측점번호+ + // 잔여거리 합산값이 그대로 임시 배치 좌표가 된다). + if (event.key === "Enter" && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + // Tab = 화면 순서(위→아래)대로 다음 입력인 상세 옵션 첫 칸으로. 위치 확정으로 + // 옵션 잠금이 풀리는 시점이 브라우저 기본 탭 계산보다 늦어, disabled 상태를 본 + // 탭이 메모까지 건너뛰던 문제(2026-08-19 사용자 보고). + if (event.key === "Tab" && !event.shiftKey) { + const inputs = visiblePositionInputs(); + if (document.activeElement === inputs[inputs.length - 1]) { + event.preventDefault(); + (document.activeElement as HTMLElement).blur(); // change → 위치 확정 → 잠금 해제 + window.requestAnimationFrame(focusFirstDetailInput); + } + } + }); + + ctx.memoField.addEventListener("change", () => ctx.liveCommit()); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts new file mode 100644 index 00000000..a04f3f5f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Types.ts + * 구조물 배치 패널의 공개 타입과 표시 상수. + * + * `B05_Profile_UI_Structures_Panel` 이 700줄을 넘겨 **타입·상수만** 떼어낸 조각이다 + * (2026-09-04). 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 + * 그대로 동작한다. + * ========================================================================== */ + +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; + +/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. + * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ +export const GROUP_LABELS: Record = { + A: "A 횡단배수", + B: "B 종단배수", + C: "C 사면안정", + D: "D 계류·사방", + E: "E 안전·부대·용지", + F: "F 생태·녹화", + G: "G 노면공", + 호환: "기타", +}; + +/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ +export const DEFAULT_INTERVAL_LENGTH_M = 15; + +/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */ +export interface PipeFacilityItem { + chainage_m: number; + facility: PipeFacility; + start_m?: number; + end_m?: number; + /** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */ + source?: PipeSource; + /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */ + design_flow_m3s?: number | null; + /** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */ + options?: Record; +} + +export interface StructuresSection { + root: HTMLElement; + /** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구 + * 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */ + facility: FacilityOptionsForm; + /** 배치된 구조물 목록(
      ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 + * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ + listRoot: HTMLElement; + /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ + setTypes: (types: StructureType[]) => void; + /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ + setStructures: (structures: StructureInstance[]) => void; + getStructures: () => StructureInstance[]; + /** 계곡 통과 시설 목록을 병합 표시한다(정본 = pipe_points, 배수유역 패널 경유). */ + setPipeFacilities: (pipes: PipeFacilityItem[]) => void; + /** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */ + selectPipeByChainage: (chainageM: number | null) => void; + /** 지금 폼이 **어떤 항목을 고쳐 쓰는 중**인가 — 선택이 없으면 폼 값은 아무 데도 + * 가지 않는다(B06에서 그 상태를 안내하는 데 쓴다, 2026-08-30). */ + hasSelection: () => boolean; + /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ + selectById: (structureId: string | null) => void; + /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ + addAt: (chainageM: number, typeId: string) => void; + /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ + moveById: (structureId: string, toChainageM: number) => boolean; + removeById: (structureId: string) => boolean; +} + +export interface StructuresCallbacks { + /** 목록이 바뀔 때(추가·수정·삭제) 전체 목록을 넘긴다. */ + onChange: (structures: StructureInstance[]) => void; + /** 목록에서 고르거나 해제할 때. */ + onSelect: (structure: StructureInstance | null) => void; + /** 측점간격(m) — 측점번호+잔여거리 ↔ 누가거리 환산에 쓴다. */ + getInterval: () => number; + /** 계곡 통과 시설 추가 — 관 지점 정본에 넣는다(배수유역 패널 경유). + * attributes에 시설 종류·구간·부속 옵션이 담긴다. */ + onPipeAdd: (chainageM: number, attributes: FacilityAttributes) => void; + /** 계곡 통과 시설 수정 — 기준점 이동·구간·부속 옵션 반영(관 지점 정본 경유). */ + onPipeUpdate: ( + fromChainageM: number, + toChainageM: number, + attributes: FacilityAttributes, + ) => void; + /** 계곡 통과 시설 삭제. */ + onPipeRemove: (chainageM: number) => void; + /** 계곡 통과 시설을 목록에서 골랐을 때(시설 폼·유역 동기화는 배수유역 패널 몫). + * null = 재클릭 해제 — 전 화면(그래프·3D·배수유역도) 선택도 함께 푼다(2026-08-18). */ + onPipeSelect: (chainageM: number | null) => void; + /** + * 바깥(3D·종단 그래프·배수유역도·횡단도)에서 고른 것이 폼에 실릴 때 — 좌측 패널이 + * 접혀 있으면 펼친다(2026-09-04 사용자: 선택하면 좌측 패널도 함께 활성화돼야 함). + * 목록에서 직접 고른 경우엔 이미 펼쳐져 있어 아무 일도 안 한다. + */ + onReveal?: () => void; +} diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index c27e729a..d0b76f53 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -192,6 +192,14 @@ gap: var(--spacing-8); } +/* 방위 나침반을 놓을 자리 — ISO 버튼 바로 아래(2026-09-03 사용자 지시). + 버튼 줄 높이 34px + 위 여백 16px 아래에 붙인다. 위젯 모양·색은 공용 + (`ui_template/ui_template_compass.css`)이고 여기서는 위치만 준다. */ +.b05-route__compass { + top: calc(var(--spacing-16) + 34px + var(--spacing-8)); + left: var(--spacing-16); +} + .b05-route__view-group { display: flex; gap: var(--spacing-4); @@ -501,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; @@ -544,377 +554,20 @@ text-overflow: ellipsis; } -.b05-route-profile__balance-reset { - flex: 0 0 auto; - padding: 1px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-caption); - cursor: pointer; -} - -/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── - 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. - 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ -.b05-profile-table { - position: relative; - display: flex; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); - color: var(--color-text-body); - /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ - font-size: var(--b05-table-font, 11px); - line-height: 1.1; - user-select: none; -} - -/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ -.b05-profile-table__row { - position: relative; - flex: 1 1 0; - min-height: 12px; - border-bottom: 1px solid var(--color-border); - white-space: nowrap; -} - -/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ -.b05-profile-table__row.is-group-start { - border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); -} - -.b05-profile-table__row--grade { - background: color-mix(in srgb, var(--color-surface) 55%, transparent); -} - -.b05-profile-table__row:last-child { - border-bottom: 0; -} - -/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ -.b05-profile-table__label { - position: sticky; - z-index: 5; - left: 0; +/* 도구줄 — 요약줄 맨 앞(최대 기울기 칩 왼쪽). 버튼 크기는 그래프 위 틸팅 버튼과 같다 + (21×17px, 2026-09-02 사용자 지시). */ +.b05-route-profile__tools { display: inline-flex; + flex: 0 0 auto; align-items: center; - /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 - (2026-08-05 사용자 지시). */ - justify-content: flex-end; - box-sizing: border-box; - width: var(--b05-table-label-width, 60px); - height: 100%; - padding-inline: var(--spacing-16) var(--spacing-4); - border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--b05-table-label-font, inherit); - font-weight: var(--font-weight-medium); - white-space: nowrap; + gap: 2px; + margin-right: var(--spacing-8); } -.b05-profile-table__cell, -.b05-profile-table__segment, -.b05-profile-table__curve { - position: absolute; - top: 0; - display: flex; - align-items: center; - justify-content: center; - height: 100%; - overflow: hidden; -} - -/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 - 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ -.b05-profile-table__curve.is-rotated input { - writing-mode: vertical-rl; - width: 100%; - height: 100%; - padding: 0; - text-align: center; -} - -/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. - 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ -.b05-profile-table__cell, -.b05-profile-table__curve { - box-sizing: border-box; - width: var(--b05-table-cell-width, 56px); - border-left: 1px solid var(--color-border); - transform: translateX(-50%); -} - -.b05-profile-table__cell:last-child, -.b05-profile-table__curve:last-child { - border-right: 1px solid var(--color-border); -} - -/* 구조물 측점 빈 칸의 세로선은 이웃 규칙 측점의 값 한가운데를 지나고, 곡선 L/R 칸 - 경계와 x가 겹쳐 곡선 행 구분선이 위 행들까지 이어진 것처럼 보인다. 곡선 행에만 - 남기고 값 행에서는 지운다(2026-08-30 사용자 지시). */ -.b05-profile-table__cell.is-structure { - border-left-color: transparent; -} - -.b05-profile-table__row.is-cut .b05-profile-table__cell { - color: rgb(220 38 38); -} - -.b05-profile-table__row.is-fill .b05-profile-table__cell { - color: rgb(37 99 235); -} - -.b05-profile-table__row.is-plan .b05-profile-table__cell { - color: var(--color-royal-amethyst, rgb(109 40 217)); - font-weight: var(--font-weight-medium); -} - -/* 구간 블록: 변화점 사이를 빈틈없이 채운다. - 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 - 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ -.b05-profile-table__segment { - box-sizing: border-box; - border-left: 1px solid var(--color-border); -} - -.b05-profile-table__segment:last-child { - border-right: 1px solid var(--color-border); -} - -.b05-profile-table__segment.is-violation { - background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); - color: rgb(180 83 9); -} - -/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ -.b05-profile-table__segment-value { - line-height: 1; - white-space: nowrap; -} - -.b05-profile-table__segment-value.is-rotated { - transform: rotate(-90deg); -} - -.b05-profile-table__curve { - z-index: 2; -} - -.b05-profile-table__curve.is-optional { - opacity: 0.75; -} - -.b05-profile-table__curve.is-omitted { - text-decoration: line-through; -} - -.b05-profile-table__radius { - box-sizing: border-box; - width: 100%; - height: 90%; - padding: 0 1px; - border: 1px solid var(--color-border); - border-radius: 2px; - background: var(--color-surface); - color: var(--color-text-body); - font-size: inherit; - text-align: center; -} - -/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */ -.b05-profile-table__no-spin::-webkit-outer-spin-button, -.b05-profile-table__no-spin::-webkit-inner-spin-button { - margin: 0; - -webkit-appearance: none; - appearance: none; -} - -.b05-profile-table__no-spin { - -moz-appearance: textfield; - appearance: textfield; -} - -/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── - 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ -/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ - -.b05-profile-chart__context-menu { - position: absolute; - z-index: 7; - display: flex; - flex-direction: column; - min-width: 120px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-profile-chart__context-menu[hidden] { - display: none; -} - -.b05-profile-chart__context-menu-item { - padding: var(--spacing-4) var(--spacing-8); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 12px; - text-align: left; - cursor: pointer; -} - -.b05-profile-chart__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. - 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ -.b05-profile-chart__context-menu, -.b05-drainage__context-menu { - max-height: 320px; - overflow-y: auto; -} - -.b05-profile-chart__context-menu-item.is-group, -.b05-drainage__context-menu-item.is-group { - font-weight: var(--font-weight-medium); -} - -.b05-profile-chart__context-menu-sub, -.b05-drainage__context-menu-sub { - display: flex; - flex-direction: column; -} - -.b05-profile-chart__context-menu-item.is-sub, -.b05-drainage__context-menu-item.is-sub { - padding-left: var(--spacing-24); -} - -.b05-profile-table__irregular-col { - position: absolute; - /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 - 행 제목이 가려지지 않는다. */ - z-index: 4; - top: 0; - bottom: 0; - display: flex; - flex-direction: column; - box-sizing: border-box; - border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); - background: color-mix( - in srgb, - var(--color-royal-amethyst, rgb(109 40 217)) 14%, - var(--color-surface) - ); - transform: translateX(-50%); - /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ - pointer-events: none; -} - -/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. - 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ -.b05-profile-table__irregular-col.is-floating { - border-inline-width: 2px; - box-shadow: - 0 0 0 1px var(--color-surface-raised), - 0 4px 12px rgb(0 0 0 / 28%); -} - -.b05-profile-table__irregular-col-cell { - display: flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-height: 0; - overflow: hidden; - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); - color: var(--color-text); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ -.b05-profile-table__irregular-col-cell.is-split { - gap: 0; -} - -.b05-profile-table__irregular-col-half { - display: flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-width: 0; - height: 100%; - overflow: hidden; - font-size: 0.72em; -} - -.b05-profile-table__irregular-col-half:first-child { - border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); -} - -.b05-profile-table__irregular-col-cell:last-child { - border-bottom: 0; -} - -.b05-profile-table__irregular-col-cell.is-cut { - color: rgb(220 38 38); -} - -.b05-profile-table__irregular-col-cell.is-fill { - color: rgb(37 99 235); -} - -/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */ - -/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ -.b05-profile-table__irregular-input { - box-sizing: border-box; - width: 92%; - height: 88%; - padding: 0; - /* 계획고만 테두리·색으로 따로 강조하지 않는다 — 같은 열의 다른 값과 같아 보여야 한다 - (2026-08-02 사용자 지시). 고칠 수 있다는 표시는 커서와 포커스 테두리로 충분하다. */ - border: 1px solid transparent; - border-radius: 2px; - background: transparent; - color: inherit; - font: inherit; - text-align: center; - pointer-events: auto; -} - -.b05-profile-table__irregular-input:focus, -.b05-profile-table__irregular-input:hover { - border-color: var(--color-royal-amethyst, rgb(109 40 217)); - background: var(--color-surface); -} - -/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ -.b05-profile-edit { - position: absolute; - z-index: 4; - inset: 0; - pointer-events: none; -} - -/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기는 ▲▼·↺·⬆⬇ 전부 21×17로 - 키웠다(2026-08-04 사용자 지시 — 18×15는 누르기도 읽기도 작았다). */ -.b05-profile-edit__btn { - position: absolute; - width: 21px; +.b05-route-profile__tool { height: 17px; - padding: 0; - font-size: 12px; - line-height: 1; + min-width: 21px; + padding: 0 3px; border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); border-radius: 3px; background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); @@ -922,360 +575,20 @@ font-size: 9px; line-height: 1; cursor: pointer; - opacity: 0.9; - pointer-events: auto; - transition: - opacity var(--transition-fast), - background var(--transition-fast); } -.b05-route-profile .b05-profile-edit__btn:hover, -.b05-route-profile .b05-profile-edit__btn:focus-visible { - border-color: var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - opacity: 1; -} - -.b05-profile-edit__btn.is-up { - top: 2px; -} - -.b05-profile-edit__btn.is-down { - bottom: 2px; -} - -/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 - 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ - -/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 - 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / - 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. - is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ -.b05-profile-edit__btn.is-up { - border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); - color: var(--color-danger); -} - -/* 구간 이동(⬆⬇)은 속 찬 화살표가 잘 읽히도록 굵게(크기는 공통 21×17). */ -.b05-profile-edit__btn.is-segment { - font-weight: 700; -} - -.b05-profile-edit__btn.is-down { - border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); - color: var(--color-chart-0, #5b8def); -} - -.b05-route-profile .b05-profile-edit__btn.is-up:hover, -.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { - border-color: var(--color-danger); - background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); - color: var(--color-danger); -} - -.b05-route-profile .b05-profile-edit__btn.is-down:hover, -.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { - border-color: var(--color-chart-0, #5b8def); - background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); - color: var(--color-chart-0, #5b8def); -} - -.b05-profile-edit__btn.is-reset { - top: 22px; - border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - background: var(--color-surface-raised); - color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); - opacity: 0.95; -} - -/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ -.b05-drainage { - position: relative; - display: flex; - /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. - 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ - width: var(--b05-drainage-width, 38%); - min-width: 320px; - max-width: 70%; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - border-left: 1px solid var(--color-border); - background: var(--color-surface-raised); - transition: width var(--transition-fast); -} - -/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ -.b05-drainage.is-collapsed { - width: 0; - min-width: 0; -} - -.b05-drainage.is-collapsed .b05-drainage__header, -.b05-drainage.is-collapsed .b05-drainage__viewport { - display: none; -} - -/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. - 유토곡선 오버레이(z-index 5)가 그 위를 덮으면 안 보인다(2026-08-04 사용자 보고) — 위로. */ -.b05-drainage .ui-workflow-overlay__handle--side { - z-index: 6; - right: auto; - left: calc(-1 * var(--spacing-24)); - border-right: 0; - border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); -} - -.b05-drainage__header { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); - border-bottom: 1px solid var(--color-border); -} - -.b05-drainage__header h3 { - margin: 0; - color: var(--color-text); - font-size: var(--text-body-sm); -} - -/* 지도 좌상단 오버레이 — 표시 필터 버튼 줄 + 상태 문구(2026-08-17 사용자 지시로 - 헤더에서 옮겼다). 버튼 자체 여백과 별개로 지도 모서리에서 조금 띄운다. - 우측 폭을 묶는 이유: 화면 오른쪽 진행단계 오버레이(z-index 200)가 지도 위를 - 덮어, 여기까지 늘어난 버튼은 눌리지 않는다. 그 폭(패널 절반 + 우측 여백)을 뺀다. */ -.b05-drainage__map-overlay { - position: absolute; - z-index: 2; - top: var(--spacing-8); - left: var(--spacing-8); - display: flex; - flex-direction: column; - gap: var(--spacing-4); - /* 오버레이 빈 자리로는 지도 조작(팬·유역 고르기)이 그대로 통해야 한다. */ - pointer-events: none; -} - -/* 필터 버튼은 한 줄로 둔다 — 오른쪽 진행단계 오버레이에 끝이 가려도 창을 넓히거나 - 그 오버레이를 접으면 드러난다(2026-08-17 사용자 확정). 두 줄로 접는 쪽이 지도를 - 더 많이 가린다. */ -.b05-drainage__layers { - display: flex; - flex-wrap: nowrap; - gap: var(--spacing-4); - pointer-events: auto; -} - -/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. - 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ -.b05-drainage__layer-button { - /* 한 줄 유지 — 좁아져도 글자가 접히거나 쭈그러들지 않게 크기를 고정한다. */ - flex: 0 0 auto; - white-space: nowrap; - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: var(--text-caption); - opacity: 0.55; - cursor: pointer; -} - -/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ -.b05-drainage__layer-button.is-active { - border-color: var(--b05-layer-color, var(--color-border)); - box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); - color: var(--b05-layer-color, var(--color-text-body)); - opacity: 1; -} - -/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ -.b05-drainage__context-menu { - position: absolute; - z-index: 6; - display: flex; - flex-direction: column; - min-width: 130px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-drainage__context-menu[hidden] { - display: none; -} - -.b05-drainage__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b05-drainage__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -.b05-drainage__viewport { - position: relative; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; - background: var(--color-surface); - /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 - grabbing으로 바꾼다(2026-08-01 사용자 지시). */ - cursor: default; - user-select: none; - touch-action: none; -} - -.b05-drainage__image, -.b05-drainage__canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.b05-drainage__image { - object-fit: contain; - transform-origin: center; - user-select: none; - pointer-events: none; -} - -.b05-drainage__canvas { - pointer-events: none; -} - -/* 상태 문구는 좌상단 오버레이가 세로로 쌓아 준다 — 필터 버튼 줄 바로 아래. */ -.b05-drainage__status { - color: var(--color-text-secondary); - font-size: var(--text-caption); - pointer-events: none; -} - -.b05-drainage__analyze { - margin-left: auto; - padding: 2px var(--spacing-8); - border: 1px solid - color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-caption); - cursor: pointer; -} - -.b05-drainage__analyze:disabled { - opacity: 0.45; +.b05-route-profile__tool:disabled { + opacity: 0.35; cursor: default; } -/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ -.b05-drainage__tool { - margin-left: var(--spacing-4, 4px); +.b05-route-profile__tool.is-active { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 22%, transparent); } -/* 배관 편집 토글 활성 상태. */ -.b05-drainage__analyze.is-active { - background: color-mix( - in srgb, - var(--color-royal-amethyst, rgb(109 40 217)) 18%, - var(--color-surface) - ); - border-color: var(--color-royal-amethyst, rgb(109 40 217)); -} - -/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ -/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). - 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ -.b05-drainage__basins { - --b05-basin-row: 34px; - - display: flex; - max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); - flex: 0 0 auto; - flex-direction: column; - gap: 2px; - overflow-y: auto; - padding: var(--spacing-8); - border-top: 1px solid var(--color-border); -} - -/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ -.b05-drainage__summary { - flex: 0 0 auto; - padding: var(--spacing-4) var(--spacing-8); - border-top: 1px solid var(--color-border); - color: var(--color-text-muted, var(--color-text-body)); - font-size: 12px; -} - -.b05-drainage__summary[hidden] { - display: none; -} - -.b05-drainage__basin { - display: flex; - min-height: var(--b05-basin-row); - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid transparent; - border-radius: var(--radius-inputs); - background: none; - color: var(--color-text-body); +.b05-route-profile__tool-hint { + color: var(--color-text-muted); font-size: var(--text-caption); - text-align: left; - cursor: pointer; -} - -.b05-drainage__basin:hover, -.b05-drainage__basin.is-selected { - border-color: var(--color-border); - background: var(--color-surface); -} - -/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ -.b05-drainage__basin-index { - display: inline-flex; - width: 20px; - height: 20px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: 50%; - color: #1f2937; - font-size: 11px; - font-weight: 600; -} - -.b05-drainage__basin-metrics { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ -.b05-profile-table__segment.is-structure { - opacity: 0.55; -} - -.b05-profile-table__segment.is-structure.is-highlight { - opacity: 1; - background: color-mix(in srgb, var(--color-primary) 14%, transparent); - outline: 1px solid var(--color-primary); + font-style: normal; } diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css new file mode 100644 index 00000000..cf5e4867 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -0,0 +1,289 @@ +/* ============================================================================= + * B05_Profile_UI_Style_Drainage.css + * 하단 종단 패널 안쪽 **우측 배수유역도** 2단 사이드 패널. + * + * `B05_Profile_UI_Style.css` 가 700줄을 넘겨 잘라낸 조각이다(2026-09-04). + * 규칙·순서·값 그대로 옮겼다. + * ========================================================================== */ + +/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ +.b05-drainage { + position: relative; + display: flex; + /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. + 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ + width: var(--b05-drainage-width, 38%); + min-width: 320px; + max-width: 70%; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: width var(--transition-fast); +} + +/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ +.b05-drainage.is-collapsed { + width: 0; + min-width: 0; +} + +.b05-drainage.is-collapsed .b05-drainage__header, +.b05-drainage.is-collapsed .b05-drainage__viewport { + display: none; +} + +/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. + 유토곡선 오버레이(z-index 5)가 그 위를 덮으면 안 보인다(2026-08-04 사용자 보고) — 위로. */ +.b05-drainage .ui-workflow-overlay__handle--side { + z-index: 6; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +.b05-drainage__header { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); + border-bottom: 1px solid var(--color-border); +} + +.b05-drainage__header h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +/* 지도 좌상단 오버레이 — 표시 필터 버튼 줄 + 상태 문구(2026-08-17 사용자 지시로 + 헤더에서 옮겼다). 버튼 자체 여백과 별개로 지도 모서리에서 조금 띄운다. + 우측 폭을 묶는 이유: 화면 오른쪽 진행단계 오버레이(z-index 200)가 지도 위를 + 덮어, 여기까지 늘어난 버튼은 눌리지 않는다. 그 폭(패널 절반 + 우측 여백)을 뺀다. */ +.b05-drainage__map-overlay { + position: absolute; + z-index: 2; + top: var(--spacing-8); + left: var(--spacing-8); + display: flex; + flex-direction: column; + gap: var(--spacing-4); + /* 오버레이 빈 자리로는 지도 조작(팬·유역 고르기)이 그대로 통해야 한다. */ + pointer-events: none; +} + +/* 필터 버튼은 한 줄로 둔다 — 오른쪽 진행단계 오버레이에 끝이 가려도 창을 넓히거나 + 그 오버레이를 접으면 드러난다(2026-08-17 사용자 확정). 두 줄로 접는 쪽이 지도를 + 더 많이 가린다. */ +.b05-drainage__layers { + display: flex; + flex-wrap: nowrap; + gap: var(--spacing-4); + pointer-events: auto; +} + +/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. + 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ +.b05-drainage__layer-button { + /* 한 줄 유지 — 좁아져도 글자가 접히거나 쭈그러들지 않게 크기를 고정한다. */ + flex: 0 0 auto; + white-space: nowrap; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.55; + cursor: pointer; +} + +/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ +.b05-drainage__layer-button.is-active { + border-color: var(--b05-layer-color, var(--color-border)); + box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); + color: var(--b05-layer-color, var(--color-text-body)); + opacity: 1; +} + +/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ +.b05-drainage__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 130px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-drainage__context-menu[hidden] { + display: none; +} + +.b05-drainage__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b05-drainage__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +.b05-drainage__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 + grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; + touch-action: none; +} + +.b05-drainage__image, +.b05-drainage__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b05-drainage__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b05-drainage__canvas { + pointer-events: none; +} + +/* 상태 문구는 좌상단 오버레이가 세로로 쌓아 준다 — 필터 버튼 줄 바로 아래. */ +.b05-drainage__status { + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +} + +.b05-drainage__analyze { + margin-left: auto; + padding: 2px var(--spacing-8); + border: 1px solid + color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); + cursor: pointer; +} + +.b05-drainage__analyze:disabled { + opacity: 0.45; + cursor: default; +} + +/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ +.b05-drainage__tool { + margin-left: var(--spacing-4, 4px); +} + +/* 배관 편집 토글 활성 상태. */ +.b05-drainage__analyze.is-active { + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 18%, + var(--color-surface) + ); + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + +/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ +/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). + 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ +.b05-drainage__basins { + --b05-basin-row: 34px; + + display: flex; + max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ +.b05-drainage__summary { + flex: 0 0 auto; + padding: var(--spacing-4) var(--spacing-8); + border-top: 1px solid var(--color-border); + color: var(--color-text-muted, var(--color-text-body)); + font-size: 12px; +} + +.b05-drainage__summary[hidden] { + display: none; +} + +.b05-drainage__basin { + display: flex; + min-height: var(--b05-basin-row); + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-inputs); + background: none; + color: var(--color-text-body); + font-size: var(--text-caption); + text-align: left; + cursor: pointer; +} + +.b05-drainage__basin:hover, +.b05-drainage__basin.is-selected { + border-color: var(--color-border); + background: var(--color-surface); +} + +/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ +.b05-drainage__basin-index { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 50%; + color: #1f2937; + font-size: 11px; + font-weight: 600; +} + +.b05-drainage__basin-metrics { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 5ee9930a..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; @@ -112,6 +118,24 @@ padding-top: 0; } +/* 재계산 대기 표시 — 곡선을 지우지 않고(2026-09-03 깜빡임 제거) 곡선 영역 우측 상단에 + **떠 있는** 알림으로 둔다. 요약 막대에 붙이면 막대가 줄바꿈되며 곡선을 밀어냈다 + (2026-09-04 사용자 보고). 범례(top: 4px) 아래 줄에 앉혀 서로 가리지 않는다. */ +.b05-profile__masshaul-pending { + position: absolute; + top: 32px; + right: var(--spacing-8); + z-index: 4; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.2; + pointer-events: none; +} + .b05-profile__masshaul-empty { padding: var(--spacing-8) var(--spacing-16); color: var(--color-text-secondary); @@ -149,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..789350c7 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -0,0 +1,243 @@ +/* 계획노선 편집 모달 — 큰 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다 + (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 사용자 지시 ③). + 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. + 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ +.b05-routeedit__label { + /* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라 + 안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */ + position: fixed; + z-index: calc(var(--z-modal, 1000) + 1); + display: flex; + flex-direction: column; + gap: 4px; + min-width: 12rem; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + /* 모달 본체와 **같은 토큰**을 쓴다 — `--color-surface-2` 는 이 테마에 없어 밝은 + 기본값으로 떨어지면서 다크 화면에서 글자가 안 읽혔다(2026-09-07 실화면). */ + background: var(--color-surface-raised); + color: var(--color-text-body); + box-shadow: 0 4px 14px rgb(0 0 0 / 45%); + font-size: var(--text-caption); +} + +/* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */ +.b05-routeedit__label-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; +} + +/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */ +.b05-routeedit__lock { + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-secondary); + font: inherit; + cursor: pointer; +} + +.b05-routeedit__lock.is-on { + border-color: transparent; + background: var(--color-primary, #7c3aed); + color: #fff; +} + +.b05-routeedit__lock:disabled { + color: var(--color-text-secondary); + cursor: default; +} + +/* 「칸을 비우면 자동」 — 상태 설명과 줄을 나눈다(2026-09-07 사용자 지시 4). */ +.b05-routeedit__curve-note { + color: var(--color-text-secondary); +} + +.b05-routeedit__label-toggle { + padding: 1px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + cursor: pointer; +} + +.b05-routeedit__label-toggle:disabled { + color: var(--color-text-secondary); + cursor: default; +} + +.b05-routeedit__curve-label { + font-weight: 600; + white-space: nowrap; +} + +.b05-routeedit__curve-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + white-space: nowrap; +} + +.b05-routeedit__curve-radius, +.b05-routeedit__curve-arc { + width: 5rem; + padding: 2px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + text-align: right; +} + +.b05-routeedit__curve-radius:disabled, +.b05-routeedit__curve-arc:disabled { + color: var(--color-text-secondary); +} + +.b05-routeedit__curve-info { + max-width: 15rem; + color: var(--color-text-secondary); + line-height: 1.35; +} 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_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css new file mode 100644 index 00000000..5b5fec88 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -0,0 +1,521 @@ +/* ============================================================================= + * B05_Profile_UI_Style_Table.css + * 종단면도 하단 **도면 테이블**과 그 위에 얹히는 것들 — + * 표(구배~곡선) · 선택 측점 값 열 오버레이 · 그래프 우클릭 메뉴 · + * 계획고 편집 버튼 · 줌/Y레인지 조작구. + * + * `B05_Profile_UI_Style.css` 가 700줄을 넘겨 **잘라낸 조각**이다(2026-09-04). + * 규칙은 원본 순서 그대로 옮겼고 값은 하나도 바꾸지 않았다 — 진입 TS 가 + * 본체 다음에 이 파일을 불러오므로 캐스케이드 순서도 같다. + * ========================================================================== */ + +/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── + 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. + 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ +.b05-profile-table { + position: relative; + display: flex; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); + color: var(--color-text-body); + /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ + font-size: var(--b05-table-font, 11px); + line-height: 1.1; + user-select: none; +} + +/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ +.b05-profile-table__row { + position: relative; + flex: 1 1 0; + min-height: 12px; + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ +.b05-profile-table__row.is-group-start { + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); +} + +.b05-profile-table__row--grade { + background: color-mix(in srgb, var(--color-surface) 55%, transparent); +} + +.b05-profile-table__row:last-child { + border-bottom: 0; +} + +/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ +.b05-profile-table__label { + position: sticky; + z-index: 5; + left: 0; + display: inline-flex; + align-items: center; + /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 + (2026-08-05 사용자 지시). */ + justify-content: flex-end; + box-sizing: border-box; + width: var(--b05-table-label-width, 60px); + height: 100%; + padding-inline: var(--spacing-16) var(--spacing-4); + border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: var(--b05-table-label-font, inherit); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.b05-profile-table__cell, +.b05-profile-table__segment, +.b05-profile-table__curve { + position: absolute; + top: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + overflow: hidden; +} + +/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 + 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ +.b05-profile-table__curve.is-rotated input { + writing-mode: vertical-rl; + width: 100%; + height: 100%; + padding: 0; + text-align: center; +} + +/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. + 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ +.b05-profile-table__cell, +.b05-profile-table__curve { + box-sizing: border-box; + width: var(--b05-table-cell-width, 56px); + border-left: 1px solid var(--color-border); + transform: translateX(-50%); +} + +.b05-profile-table__cell:last-child, +.b05-profile-table__curve:last-child { + border-right: 1px solid var(--color-border); +} + +/* 구조물 측점 빈 칸의 세로선은 이웃 규칙 측점의 값 한가운데를 지나고, 곡선 L/R 칸 + 경계와 x가 겹쳐 곡선 행 구분선이 위 행들까지 이어진 것처럼 보인다. 곡선 행에만 + 남기고 값 행에서는 지운다(2026-08-30 사용자 지시). */ +.b05-profile-table__cell.is-structure { + border-left-color: transparent; +} + +.b05-profile-table__row.is-cut .b05-profile-table__cell { + color: rgb(220 38 38); +} + +.b05-profile-table__row.is-fill .b05-profile-table__cell { + color: rgb(37 99 235); +} + +.b05-profile-table__row.is-plan .b05-profile-table__cell { + color: var(--color-royal-amethyst, rgb(109 40 217)); + font-weight: var(--font-weight-medium); +} + +/* 구간 블록: 변화점 사이를 빈틈없이 채운다. + 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 + 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ +.b05-profile-table__segment { + box-sizing: border-box; + border-left: 1px solid var(--color-border); +} + +.b05-profile-table__segment:last-child { + border-right: 1px solid var(--color-border); +} + +.b05-profile-table__segment.is-violation { + background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); + color: rgb(180 83 9); +} + +/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ +.b05-profile-table__segment-value { + line-height: 1; + white-space: nowrap; +} + +.b05-profile-table__segment-value.is-rotated { + transform: rotate(-90deg); +} + +.b05-profile-table__curve { + z-index: 2; +} + +.b05-profile-table__curve.is-optional { + opacity: 0.75; +} + +.b05-profile-table__curve.is-omitted { + text-decoration: line-through; +} + +.b05-profile-table__radius { + box-sizing: border-box; + width: 100%; + height: 90%; + padding: 0 1px; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--color-surface); + color: var(--color-text-body); + font-size: inherit; + text-align: center; +} + +/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */ +.b05-profile-table__no-spin::-webkit-outer-spin-button, +.b05-profile-table__no-spin::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.b05-profile-table__no-spin { + -moz-appearance: textfield; + appearance: textfield; +} + +/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── + 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ +/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ + +.b05-profile-chart__context-menu { + position: absolute; + z-index: 7; + display: flex; + flex-direction: column; + min-width: 120px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-profile-chart__context-menu[hidden] { + display: none; +} + +.b05-profile-chart__context-menu-item { + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.b05-profile-chart__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. + 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ +.b05-profile-chart__context-menu, +.b05-drainage__context-menu { + max-height: 320px; + overflow-y: auto; +} + +.b05-profile-chart__context-menu-item.is-group, +.b05-drainage__context-menu-item.is-group { + font-weight: var(--font-weight-medium); +} + +.b05-profile-chart__context-menu-sub, +.b05-drainage__context-menu-sub { + display: flex; + flex-direction: column; +} + +.b05-profile-chart__context-menu-item.is-sub, +.b05-drainage__context-menu-item.is-sub { + padding-left: var(--spacing-24); +} + +.b05-profile-table__irregular-col { + position: absolute; + /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 + 행 제목이 가려지지 않는다. */ + z-index: 4; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + box-sizing: border-box; + border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 14%, + var(--color-surface) + ); + transform: translateX(-50%); + /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ + pointer-events: none; +} + +/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. + 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ +.b05-profile-table__irregular-col.is-floating { + border-inline-width: 2px; + box-shadow: + 0 0 0 1px var(--color-surface-raised), + 0 4px 12px rgb(0 0 0 / 28%); +} + +.b05-profile-table__irregular-col-cell { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-height: 0; + overflow: hidden; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + color: var(--color-text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ +.b05-profile-table__irregular-col-cell.is-split { + gap: 0; +} + +.b05-profile-table__irregular-col-half { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-width: 0; + height: 100%; + overflow: hidden; + font-size: 0.72em; +} + +.b05-profile-table__irregular-col-half:first-child { + border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); +} + +.b05-profile-table__irregular-col-cell:last-child { + border-bottom: 0; +} + +.b05-profile-table__irregular-col-cell.is-cut { + color: rgb(220 38 38); +} + +.b05-profile-table__irregular-col-cell.is-fill { + color: rgb(37 99 235); +} + +/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */ + +/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ +.b05-profile-table__irregular-input { + box-sizing: border-box; + width: 92%; + height: 88%; + padding: 0; + /* 계획고만 테두리·색으로 따로 강조하지 않는다 — 같은 열의 다른 값과 같아 보여야 한다 + (2026-08-02 사용자 지시). 고칠 수 있다는 표시는 커서와 포커스 테두리로 충분하다. */ + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + color: inherit; + font: inherit; + text-align: center; + pointer-events: auto; +} + +.b05-profile-table__irregular-input:focus, +.b05-profile-table__irregular-input:hover { + border-color: var(--color-royal-amethyst, rgb(109 40 217)); + background: var(--color-surface); +} + +/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ +/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 + (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ +.b05-profile-runmark { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; +} + +.b05-profile-runmark__line { + fill: none; + stroke: var(--color-danger, #d64545); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ +.b05-profile-runmark__edge { + stroke: var(--color-danger, #d64545); + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.5; +} + +.b05-profile-edit { + position: absolute; + z-index: 4; + inset: 0; + pointer-events: none; +} + +/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기는 ▲▼·↺·⬆⬇ 전부 21×17로 + 키웠다(2026-08-04 사용자 지시 — 18×15는 누르기도 읽기도 작았다). */ +.b05-profile-edit__btn { + position: absolute; + width: 21px; + height: 17px; + padding: 0; + font-size: 12px; + line-height: 1; + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); + border-radius: 3px; + background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); + color: var(--color-text); + font-size: 9px; + line-height: 1; + cursor: pointer; + opacity: 0.9; + pointer-events: auto; + transition: + opacity var(--transition-fast), + background var(--transition-fast); +} + +.b05-route-profile .b05-profile-edit__btn:hover, +.b05-route-profile .b05-profile-edit__btn:focus-visible { + border-color: var(--color-border); + background: var(--color-surface-raised); + color: var(--color-text); + opacity: 1; +} + +.b05-profile-edit__btn.is-up { + top: 2px; +} + +.b05-profile-edit__btn.is-down { + bottom: 2px; +} + +/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 + 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ + +/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 + 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / + 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. + is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ +.b05-profile-edit__btn.is-up { + border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); + color: var(--color-danger); +} + +/* 구간 이동(⬆⬇)은 속 찬 화살표가 잘 읽히도록 굵게(크기는 공통 21×17). */ +.b05-profile-edit__btn.is-segment { + font-weight: 700; +} + +.b05-profile-edit__btn.is-down { + border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); + color: var(--color-chart-0, #5b8def); +} + +.b05-route-profile .b05-profile-edit__btn.is-up:hover, +.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); + color: var(--color-danger); +} + +.b05-route-profile .b05-profile-edit__btn.is-down:hover, +.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { + border-color: var(--color-chart-0, #5b8def); + background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); + color: var(--color-chart-0, #5b8def); +} + +.b05-profile-edit__btn.is-reset { + top: 22px; + border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + background: var(--color-surface-raised); + color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); + opacity: 0.95; +} +.b05-profile-table__segment.is-structure { + opacity: 0.55; +} + +.b05-profile-table__segment.is-structure.is-highlight { + opacity: 1; + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + outline: 1px solid var(--color-primary); +} + +/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── + * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 + * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 + * 여기서 같은 값으로 다시 적는다. */ +.b05-profile__zoom { + display: flex; + flex: 0 0 auto; + margin-left: auto; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); +} + +.b05-profile__zoom-btn { + width: 22px; + height: 22px; + padding: 0; + border: none; + border-left: 1px solid var(--color-border); + background: none; + color: var(--color-text-secondary); + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} + +.b05-profile__zoom-btn:first-child { + border-left: none; +} + +.b05-profile__zoom-btn:hover:not(:disabled) { + color: var(--color-text); + background: var(--color-surface); +} + +/* 배율 한계(축소는 폭맞춤, 확대는 8배)에 닿은 버튼 — 눌러도 변화가 없으니 흐리게 죽인다. */ +.b05-profile__zoom-btn:disabled { + opacity: 0.35; + cursor: default; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 51cd5ea1..65375e32 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { createTerrainCompass } from "@ui/ui_template_compass"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; @@ -13,7 +14,7 @@ import { type RouteMarkers, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; -import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera"; +import { createCameraRig, type ProjectionKind } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -22,6 +23,11 @@ import { PLAN_CURVE_GROUP, } from "./B05_Profile_UI_Corridor_Mesh"; import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; +import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; +import { + bindStructurePick, + type StructurePickControls, +} from "./B05_Profile_UI_Viewer_Structure_Pick"; import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; @@ -31,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; @@ -112,9 +143,13 @@ export interface RouteViewer { setCorridorVisible: (visible: boolean) => void; renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; + /** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */ + setProjection: (kind: ProjectionKind) => void; beginMoveSelected: () => void; /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; + /** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */ + structurePick: StructurePickControls; dispose: () => void; } @@ -125,7 +160,10 @@ export function createRouteViewer(): RouteViewer { status.className = "b05-route__viewer-status"; status.textContent = "확정 지표면을 불러오는 중입니다."; const canvas = document.createElement("canvas"); - root.append(canvas, status); + // 방위 나침반 — ISO 버튼 아래(2026-09-03 사용자 지시). 지면에 누운 링이라 사시도에서도 + // 화면 북쪽과 어긋나지 않는다. 위젯은 B04 지표면 뷰어와 **같은 공용 모듈**을 쓴다. + const compass = createTerrainCompass({ sizePx: 104, className: "b05-route__compass" }); + root.append(canvas, status, compass.root); const scene = new THREE.Scene(); const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)"); @@ -154,11 +192,12 @@ export function createRouteViewer(): RouteViewer { }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); - const cameraRig = createOrthoCameraRig(); - const camera = cameraRig.camera; + // 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로** + // 붙잡아 두지 말고 `cameraRig.camera()`로 그때그때 읽는다(2026-09-04 사용자 지시). + const cameraRig = createCameraRig(); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); - const controls = new OrbitControls(camera, canvas); + const controls = new OrbitControls(cameraRig.camera(), canvas); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); const directional = new THREE.DirectionalLight(0xffffff, 2.2); @@ -170,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; @@ -191,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; @@ -202,6 +255,11 @@ export function createRouteViewer(): RouteViewer { (window as unknown as { __corridorScene?: unknown }).__corridorScene = { scene, THREE, + // 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지 + // 확인해야 3D 클릭 검증을 수치로 할 수 있다. + get camera() { + return cameraRig.camera(); + }, toScene: (x: number, y: number, z: number) => bounds ? modelToScene({ x, y, z }, bounds) : null, topZ: () => (bounds ? bounds.z[1] + 100 : null), @@ -212,21 +270,39 @@ export function createRouteViewer(): RouteViewer { placeCamera(modelToScene({ x, y, z }, bounds), distance, view); return true; }, + // 모델 좌표 한 점의 **화면(client) 좌표**(2026-09-04) — 3D 물체를 실제 마우스로 + // 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다. + project: (x: number, y: number, z: number) => { + if (!bounds) return null; + const point = modelToScene({ x, y, z }, bounds).project(cameraRig.camera()); + const rect = canvas.getBoundingClientRect(); + return { + x: rect.left + ((point.x + 1) / 2) * rect.width, + y: rect.top + ((1 - point.y) / 2) * rect.height, + }; + }, }; const markers = createRouteMarkers(scene, () => bounds, terrainElevation); // 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한). const markerInput = bindMarkerPointerControls({ canvas, - camera, + camera: cameraRig.camera, controls, markers, getTerrain: () => terrain, getBounds: () => bounds, }); + // 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다. + const structurePick = bindStructurePick({ + canvas, + camera: cameraRig.camera, + group: () => corridorGroup, + blocked: () => markerInput.blocked(), + }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. const releaseCursorPivot = bindCursorPivotControls({ - camera, + camera: cameraRig.camera, controls, element: canvas, pickables: () => (terrain ? [terrain] : []), @@ -258,11 +334,15 @@ export function createRouteViewer(): RouteViewer { side: [distance, distance * 0.25, 0], } as const; const [x, y, z] = positions[view]; + const camera = cameraRig.camera(); camera.position.set(target.x + x, target.y + y, target.z + z); - camera.near = Math.max(0.1, distance / 1000); + // 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸). + // 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어 + // 최대 확대 시 지형이 잘린다(2026-09-04 원근 복귀 실측: 400m 노선 여유 13mm). + camera.near = Math.max(0.1, Math.min(0.4, distance / 1000)); camera.far = distance * 10; - // 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다. - cameraRig.setHalfHeight(distance * 0.42); + camera.updateProjectionMatrix(); + cameraRig.setFit(distance); controls.update(); } @@ -320,7 +400,20 @@ export function createRouteViewer(): RouteViewer { function animate(): void { frame = requestAnimationFrame(animate); controls.update(); - renderer.render(scene, camera); + // 나침반은 시선 방향이 실제로 바뀔 때만 다시 그린다(모듈 안에서 걸러낸다). + if (terrain) { + compass.setVisible(true); + compass.update( + cameraRig.camera().position.x - controls.target.x, + cameraRig.camera().position.y - controls.target.y, + cameraRig.camera().position.z - controls.target.z, + ); + } else { + compass.setVisible(false); + } + // 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다). + markers.updateLabelDetail(cameraRig.camera().position.distanceTo(controls.target)); + renderer.render(scene, cameraRig.camera()); } animate(); @@ -422,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; } @@ -521,50 +619,13 @@ export function createRouteViewer(): RouteViewer { function setCorridor(build: CorridorBuildResult | null): void { disposeCorridorGroup(); corridorBuild = build; - // 검증용 요약(2026-08-26) — 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 - // 수치로 확인한다(`__corridorClip`과 같은 용도). - (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build - ? { - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - rows: ribbon.chainages.length, - cols: ribbon.colCount, - first: ribbon.chainages[0], - last: ribbon.chainages[ribbon.chainages.length - 1], - })), - outlineRows: build.outline.chainages.length, - outline: build.outline, - // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). - structures: (build.structures ?? []).map((solid) => ({ - kind: solid.kind, - at: solid.chainage_m, - rings: solid.rings?.length ?? 0, - points: solid.polygon?.length ?? 0, - })), - // 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. - raw: { - ribbons: build.ribbons, - planCurves: build.planCurves ?? [], - cutWalls: build.cutWalls ?? [], - }, - planCurves: (build.planCurves ?? []).map((curve) => ({ - source: curve.source, - role: curve.role, - side: curve.side, - at: curve.setChainageM, - loops: curve.loops.length, - points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), - z0: curve.loops[0]?.[0]?.[2] ?? null, - planZ: curve.planZ, - })), - } - : null; + setCorridorBuildSummary(build); if (build && bounds) { snapCorridorEdges(build); attachPatchSkirts(build); corridorGroup = createCorridorGroup(build, bounds); scene.add(corridorGroup); + structurePick.reapply(); } // 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다 // (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음"). @@ -590,39 +651,69 @@ export function createRouteViewer(): RouteViewer { return { root, 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 = ""; @@ -652,6 +743,7 @@ export function createRouteViewer(): RouteViewer { setStationLabelsVisible: markers.setStationLabelsVisible, renderStationLines: markers.renderStationLines, setView: fit, + setProjection: (kind) => cameraRig.setKind(kind, controls), beginMoveSelected() { markerInput.beginMoveSelected(); status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; @@ -667,6 +759,7 @@ export function createRouteViewer(): RouteViewer { markers.dispose(); clearContours(); disposeObject(terrain); + structurePick.dispose(); corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 6e36d63b..32f94f3f 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -1,42 +1,81 @@ /* ============================================================================= * B05_Profile_UI_Viewer_Camera.ts - * B05 뷰어의 **직교(원근 없음) 카메라**(2026-08-25 사용자 확정) — 탑뷰에서 구조물· - * 절단 경계가 원근으로 일그러지지 않는다. 화면 배율은 camera.zoom이 지고(커서 피벗 - * 유틸이 조작), 절두체 반높이는 fit()이 정한다. Viewer 700줄 제한으로 분리. + * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. + * + * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). + * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. + * + * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. * ========================================================================== */ import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; -export interface OrthoCameraRig { - camera: THREE.OrthographicCamera; - /** 뷰포트 종횡비 반영(리사이즈 시). */ - setAspect(aspect: number): void; - /** 절두체 반높이(월드 m) 지정 — fit()이 화면 배율을 잡을 때 쓴다. zoom은 1로 되돌린다. */ - setHalfHeight(halfHeight: number): void; -} +export type ProjectionKind = "perspective" | "ortho"; -export function createOrthoCameraRig(): OrthoCameraRig { - const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - camera.position.set(100, 120, 100); - let halfHeight = 100; +const FOV = 45; +/** 원근 45°의 반각 tan — 직교 반높이를 같은 크기감으로 맞출 때 쓴다. */ +const HALF_TAN = Math.tan((FOV * Math.PI) / 360); + +export function createCameraRig() { + const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); + perspective.position.set(100, 120, 100); + const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); + let kind: ProjectionKind = "perspective"; let aspect = 1; - const apply = (): void => { - camera.left = -halfHeight * aspect; - camera.right = halfHeight * aspect; - camera.top = halfHeight; - camera.bottom = -halfHeight; - camera.updateProjectionMatrix(); - }; + let halfHeight = 100; + + const active = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => + kind === "perspective" ? perspective : ortho; + + function apply(): void { + perspective.aspect = aspect; + perspective.updateProjectionMatrix(); + ortho.left = -halfHeight * aspect; + ortho.right = halfHeight * aspect; + ortho.top = halfHeight; + ortho.bottom = -halfHeight; + ortho.updateProjectionMatrix(); + } + return { - camera, + camera: active, + kind: () => kind, + /** 뷰포트 종횡비(리사이즈 시). */ setAspect(value: number): void { aspect = value; apply(); }, - setHalfHeight(value: number): void { - halfHeight = value; - camera.zoom = 1; + /** 화면맞춤 — 시점까지 거리로 직교 반높이를 잡는다(원근과 같은 크기감). */ + setFit(distance: number): void { + halfHeight = distance * HALF_TAN; + ortho.zoom = 1; apply(); }, + /** 투영 전환. 보이는 크기를 유지하며 OrbitControls의 대상 카메라도 갈아 끼운다. */ + setKind(next: ProjectionKind, controls: OrbitControls): void { + if (next === kind) return; + const from = active(); + kind = next; + const to = active(); + to.quaternion.copy(from.quaternion); + to.near = from.near; + to.far = from.far; + const offset = from.position.clone().sub(controls.target); + if (next === "ortho") { + halfHeight = offset.length() * HALF_TAN; + ortho.zoom = 1; + to.position.copy(from.position); + } else { + // 직교는 배율(zoom)로도 커지므로, 같은 크기로 보이는 거리까지 카메라를 물린다. + to.position.copy(controls.target).add(offset.setLength(halfHeight / ortho.zoom / HALF_TAN)); + } + apply(); + controls.object = to; + controls.update(); + }, }; } + +export type CameraRig = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Debug.ts b/B05_Profile/B05_Profile_UI_Viewer_Debug.ts new file mode 100644 index 00000000..b5964869 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Debug.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Debug.ts + * 뷰어 **검증용 요약** — 코리도 빌드 결과를 `window.__corridorBuild` 에 얹는다 + * (`__corridorClip`·`__corridorScene` 과 같은 용도). + * + * `B05_Profile_UI_Viewer` 에서 그대로 떼어냈다(700줄 제한, 2026-09-04). 계산은 없고 + * 요약 형태만 있으므로 화면 동작에는 영향이 없다. + * ========================================================================== */ + +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; + +/** + * 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 수치로 확인한다(2026-08-26). + * `raw` 는 원본 참조라 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. + */ +export function setCorridorBuildSummary(build: CorridorBuildResult | null): void { + (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build + ? { + ribbons: build.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + rows: ribbon.chainages.length, + cols: ribbon.colCount, + first: ribbon.chainages[0], + last: ribbon.chainages[ribbon.chainages.length - 1], + })), + outlineRows: build.outline.chainages.length, + outline: build.outline, + // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). + // 부재키(2026-09-04)도 함께 낸다 — 3D 개별 선택이 무엇을 집었는지 대조한다. + structures: (build.structures ?? []).map((solid) => ({ + kind: solid.kind, + key: solid.key ?? null, + at: solid.chainage_m, + rings: solid.rings?.length ?? 0, + points: solid.polygon?.length ?? 0, + })), + raw: { + ribbons: build.ribbons, + planCurves: build.planCurves ?? [], + cutWalls: build.cutWalls ?? [], + }, + planCurves: (build.planCurves ?? []).map((curve) => ({ + source: curve.source, + role: curve.role, + side: curve.side, + at: curve.setChainageM, + loops: curve.loops.length, + points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), + z0: curve.loops[0]?.[0]?.[2] ?? null, + planZ: curve.planZ, + })), + } + : null; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index 6ff036bb..afeb0ab9 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -30,7 +30,8 @@ export interface MarkerPointerControls { export function bindMarkerPointerControls(options: { canvas: HTMLCanvasElement; - camera: THREE.Camera; + /** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */ + camera: () => THREE.Camera; controls: OrbitControls; markers: RouteMarkers; getTerrain: () => THREE.Object3D | null; @@ -60,7 +61,7 @@ export function bindMarkerPointerControls(options: { const bounds = getBounds(); if (!terrain || !bounds) return null; const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointerOf(clientX, clientY), camera); + raycaster.setFromCamera(pointerOf(clientX, clientY), camera()); const hit = raycaster.intersectObject(terrain, true)[0]; return hit ? sceneToModel(hit.point, bounds) : null; } @@ -84,7 +85,7 @@ export function bindMarkerPointerControls(options: { function markerHit(event: PointerEvent): THREE.Object3D | undefined { const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera); + raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera()); return raycaster.intersectObject(markers.group, true)[0]?.object; } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts new file mode 100644 index 00000000..4235b2e4 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -0,0 +1,172 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Structure_Pick.ts + * 3D 코리도 **구조물 개별 선택**(2026-09-04 사용자 지시) — 부재 하나를 클릭으로 골라 + * 밝히고, 고른 신원표를 바깥으로 넘긴다. 정보상자는 만들지 않는다(좌측 「구조물 배치」 + * 패널이 그 일을 한다). + * + * 뷰어·마커 입력에 얹지 않고 새 모듈로 뒀다(700줄 제한). 규칙 두 가지 — + * · **마커보다 뒤 순위**: 마커를 잡고 있거나 이동 대기 중이면 선택을 건너뛴다. + * · **누른 자리에서 3px 안에서 뗐을 때만** 선택으로 본다(카메라 회전과 구분). + * ========================================================================== */ + +import * as THREE from "three"; + +/** 3D에서 고른 부재의 신원표 — 메쉬 `userData`에 실려 있는 그 값이다. */ +export interface StructurePick { + chainageM: number; + kind: string; + /** 부재키(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·`basin`·`pipe`). 없을 수 있다. */ + key?: string; +} + +export interface StructurePickControls { + /** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */ + onPick?: (pick: StructurePick | null) => void; + /** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */ + selectAtChainage: (chainageM: number | null) => void; + /** 부재키까지 지정해 강조를 세운다 — 세션에 남은 선택을 되살릴 때 쓴다. */ + select: (pick: StructurePick | null) => void; + /** 코리도를 다시 만든 뒤 강조를 되살린다 — 메쉬가 통째로 새 것이라 다시 칠해야 한다. */ + reapply: () => void; + dispose: () => void; +} + +/** 고른 부재를 밝히는 자체발광색 — 원래 색은 그대로 두고 밝기만 얹는다. */ +const HIGHLIGHT = 0x2f5f9f; + +/** 클릭으로 볼 이동 허용치(px) — 마커 끌기 판정과 같은 값. */ +const CLICK_SLOP_PX = 3; + +export function bindStructurePick(options: { + canvas: HTMLCanvasElement; + /** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */ + camera: () => THREE.Camera; + /** 코리도 그룹 — 없거나 꺼져 있으면 고르지 않는다. */ + group: () => THREE.Object3D | null; + /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ + blocked: () => boolean; +}): StructurePickControls { + const { canvas, camera, group, blocked } = options; + + let selected: StructurePick | null = null; + let down: { x: number; y: number; pointerId: number } | null = null; + // 마커 "이동 대기" 모드는 마커 쪽 pointerdown이 그 자리에서 꺼 버린다 — 그전(창 캡처 + // 단계)에 한 번 물어 둔다. 마커를 집었는지는 그 뒤 캔버스 단계에서 본다. + let blockedBeforeDown = false; + + /** 메쉬(또는 그 부모)에 달린 신원표. 없으면 구조물이 아니다. */ + function tagOf(object: THREE.Object3D | null): StructurePick | null { + let node = object; + while (node) { + const data = node.userData as Partial; + if (typeof data.chainageM === "number") { + return { chainageM: data.chainageM, kind: String(data.kind ?? ""), key: data.key }; + } + node = node.parent; + } + return null; + } + + /** 이 부재가 지금 고른 것인가 — 부재키가 없는 선택(측점만)은 그 측점 전부를 켠다. */ + function matches(tag: StructurePick): boolean { + if (!selected) return false; + if (Math.abs(tag.chainageM - selected.chainageM) >= 0.01) return false; + return selected.key === undefined || tag.key === selected.key; + } + + function applyHighlight(): void { + const root = group(); + if (!root) return; + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh) return; + const tag = tagOf(mesh); + if (!tag) return; + const on = matches(tag); + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of materials) { + const lit = material as THREE.MeshLambertMaterial; + // 자체발광이 없는 재질(라인 등)은 건너뛴다. + if (lit.emissive) lit.emissive.setHex(on ? HIGHLIGHT : 0x000000); + } + }); + } + + /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */ + function pickAt(clientX: number, clientY: number): StructurePick | null { + const root = group(); + if (!root || !root.visible) return null; + const rect = canvas.getBoundingClientRect(); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera( + new THREE.Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ), + camera(), + ); + // 모서리 선(LineSegments)은 뺀다 — 라인 레이캐스트 허용반경이 1m라 클릭을 가로챈다. + const hit = raycaster + .intersectObject(root, true) + .find((entry) => (entry.object as THREE.Mesh).isMesh); + return hit ? tagOf(hit.object) : null; + } + + function handleWindowDown(): void { + blockedBeforeDown = blocked(); + } + + function handleDown(event: PointerEvent): void { + down = + event.button === 0 && !blockedBeforeDown && !blocked() + ? { x: event.clientX, y: event.clientY, pointerId: event.pointerId } + : null; + } + + function handleUp(event: PointerEvent): void { + const start = down; + down = null; + if (!start || event.pointerId !== start.pointerId) return; + if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; + if (blocked()) return; + selected = pickAt(event.clientX, event.clientY); + applyHighlight(); + controls.onPick?.(selected); + } + + function handleExit(): void { + down = null; + } + + // **캡처 단계**로 단다(2026-09-04 실측) — 캔버스에 이미 붙은 캡처 리스너가 pointerdown + // 전파를 멈춰, 같은 캔버스의 버블 리스너는 아예 불리지 않는다(마커 입력·회전 중심 + // 유틸과 같은 자리). 마커 입력보다 **나중에** 달아 순서상 뒤에 선다. + window.addEventListener("pointerdown", handleWindowDown, true); + canvas.addEventListener("pointerdown", handleDown, true); + canvas.addEventListener("pointerup", handleUp, true); + canvas.addEventListener("pointerleave", handleExit, true); + canvas.addEventListener("pointercancel", handleExit, true); + + const controls: StructurePickControls = { + selectAtChainage(chainageM) { + // 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다 — 되돌아온 동기화가 + // 부재 하나짜리 강조를 그 측점 전체로 넓히면 안 된다(2026-09-04). + if (chainageM !== null && selected && Math.abs(selected.chainageM - chainageM) < 0.01) return; + selected = chainageM === null ? null : { chainageM, kind: "" }; + applyHighlight(); + }, + select(pick) { + selected = pick; + applyHighlight(); + }, + reapply: applyHighlight, + dispose() { + window.removeEventListener("pointerdown", handleWindowDown, true); + canvas.removeEventListener("pointerdown", handleDown, true); + canvas.removeEventListener("pointerup", handleUp, true); + canvas.removeEventListener("pointerleave", handleExit, true); + canvas.removeEventListener("pointercancel", handleExit, true); + }, + }; + return controls; +} 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..e427bf2d 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,10 @@ export async function previewCrossDesigns( fullDesigns?: boolean; /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; + /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ + berms?: Record; + /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ + cutSlopeRatios?: Record; }, ): Promise { return requestJson( @@ -230,6 +268,8 @@ export async function previewCrossDesigns( standard_cross_section: standardCrossSection ?? null, full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, + berms: options?.berms ?? null, + cut_slope_ratios: options?.cutSlopeRatios ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 23d7e724..45a7dab6 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -74,6 +74,9 @@ export interface SectionContextResponse { defaults: SectionOptionDefaults; /** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */ standard_cross_section: StandardCrossSection; + /** 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 확정한 값). 없으면 null. + * 위 `standard_cross_section` 은 config 기본값이라 둘은 다른 것이다(2026-09-07). */ + stored_standard_cross_section?: StandardCrossSection | null; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; @@ -116,6 +119,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 자동 판정 + 사용자 변경. */ @@ -209,6 +218,9 @@ export interface CulvertSideSpec { /** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */ export interface CulvertSet { type: "pipe"; + /** 이 시설이 **놓인** 누가거리(m) — 세트는 옆 측점에도 붙으므로 소유 측점을 가리는 열쇠다. + * (2026-09-08 — 없을 때 관 길이가 이웃 측점에도 실려 같은 관을 두 번 셀 뻔했다.) */ + chainage_m?: number; pipe_kind: string | null; diameter_m: number; /** 관 위 최소 토피(m) — 별표2 교량·암거 복토 50㎝ 교차 참조. B05 하향 차단 기준. */ @@ -399,6 +411,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 } @@ -435,12 +452,26 @@ export interface CrossDesign { fill_area_m2: number; /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */ fill_ground_slope?: number | null; + /** + * 사면이 샘플 범위(±계산 반폭) 끝에서도 원지반과 만나지 않은 측점. 면적이 거기서 + * 잘려 절·성토량이 실제와 다르다 — 계산은 그대로 두고 카드에 경고만 단다 + * (2026-09-03 사용자 확정: 「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」). + */ + slope_unclosed?: boolean; ditch_area_m2: number; design_line: Array<{ offset_m: number; elevation_m: number }>; /** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */ rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ display_half_width_m?: number; + /** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음. + * 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */ + berm?: { width_m: number; interval_m: number; slope_deg: number }; + /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). + * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ + cut_slope_ratio_user?: number; + /** 배수관 연장(m) — 기하가 **m 단위 올림까지** 끝낸 값(2026-09-08). 수량(B08)이 읽는다. */ + pipe_length_m?: number; } export interface CrossDesignResponse { @@ -462,6 +493,8 @@ export interface CrossDesignRequest { rock_boundary_offset_m?: number | null; /** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */ two_stage_slope?: boolean; + /** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */ + cut_slope_ratio?: number | null; /** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */ ditch_enabled?: boolean | null; /** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */ @@ -474,6 +507,10 @@ export interface CrossSectionPatch { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */ display_half_width_m?: number; + /** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */ + cut_slope_ratio_user?: number; + /** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */ + pipe_length_m?: number; inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; basin_adjust?: { innerWidthM: number; @@ -489,4 +526,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 new file mode 100644 index 00000000..09951ca5 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -0,0 +1,331 @@ +/* ============================================================================= + * B06_Section_Cross_Refresh.ts + * 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다. + * + * ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ─────────── + * 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시 + * 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다 + * `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를 + * 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」). + * + * 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py` + * 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다. + * 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다. + * + * ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진 + * (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다. + * 한쪽만 고치면 화면과 저장본이 갈린다. 회귀 테스트: + * `tmp/tests/test_b06_cross_design_mirror.py` + * + * ── 왜 창구가 하나여야 하는가 (2026-09-03 실측) ────────────────────── + * 재계산 호출이 두 벌이던 시절, 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` + * ↔ B05 `4,526.8㎥` 로 갈렸다. 원인은 인자였다 — 표준 단면값과 암 경계 오프셋이 빠지면 + * 서버가 다른 설계를 그린다. 입력 수집·계산·제자리 반영을 여기 한 곳에 모아 둔다. 세션 + * 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다. + * ========================================================================== */ + +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 { 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, + readStandardCrossSession, +} from "./B06_Section_UI_Standard_Panel"; +import { + buildAlignment, + planElevationAt, + toAlignmentBase, +} from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; +import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { readState } from "../A00_Common/b_page_state"; + +/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ +export interface CrossRefreshEdits { + station_offsets: Record; + curve_radii: Record; +} + +export interface CrossRefreshInput { + projectId: string; + routeId: number; + /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ + detail: SectionDetailResponse; + edits: CrossRefreshEdits; + /** + * 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나 + * 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다. + */ + shouldApply?: () => boolean; +} + +/** + * 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로 + * 실어 보낸다(`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", + "revet_adjust", + "ford_adjust", + "box_adjust", + "extra_wall_counts", + "extra_spans", + "revet_link_detached", + "revet_follow_grade", + // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", + // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. + "cut_slope_ratio_user", +] as const; + +/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ +const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; + +function preserveUserFields( + next: NonNullable, + previous: CrossSection["design"], +): NonNullable { + if (!previous) return next; + const merged = { ...next } as Record; + const source = previous as unknown as Record; + for (const key of PRESERVED_KEYS) { + if (source[key] !== undefined) merged[key] = source[key]; + } + return merged as unknown as NonNullable; +} + +/** + * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. + * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + */ +export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + const local = refreshLocally(input); + if (local !== null) return local; + return refreshFromServer(input); +} + +/** + * 암 경계 세션 오프셋을 **자릿수에 안 휘둘리게** 읽는다. + * + * 저장하는 쪽(`createRockBoundaryStore`)은 키를 `toFixed(2)` 로 쓰고, 서버는 받은 키를 + * 숫자로 바꿔 비교했다. 로컬 계산이 문자열 키를 그대로 맞추려다 자릿수가 달라 세션값을 + * 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측 + * 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다. + */ +function rockKey(chainageM: number): number { + return Math.round(chainageM * 100) / 100; +} + +function readRockOffsets(projectId: string, routeId: number): Map { + const raw = readRockBoundarySession(projectId, routeId) ?? {}; + const offsets = new Map(); + for (const [key, value] of Object.entries(raw)) { + const chainage = Number(key); + if (Number.isFinite(chainage) && typeof value === "number" && Number.isFinite(value)) { + offsets.set(rockKey(chainage), value); + } + } + return offsets; +} + +/** + * 소단 구간을 **측점별 제원**으로 편다 — 서버는 측점키로 받기 때문이다. + * 구간 자체는 세션에 그대로 두어 「어디부터 어디까지 놓았나」를 잃지 않는다. + */ +function bermPayload( + projectId: string, + routeId: number, + detail: SectionDetailResponse, +): Record { + 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; +} + +/** + * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. + * + * **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면 + * 저장분에 남은 옛 사용자 값이 되살아난다. + */ +function readCutSlopeRatios(projectId: string, routeId: number): Map { + const out = new Map(); + const raw = readState>("cutslope", projectId, routeId); + if (!raw) return out; + for (const [chainage, ratio] of Object.entries(raw)) { + const at = Number(chainage); + if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); + } + return out; +} + +/** + * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). + * + * 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서** + * 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라 + * 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면 + * `null` 을 돌려 서버 폴백으로 넘긴다. + */ +function refreshLocally(input: CrossRefreshInput): number[] | null { + const { projectId, detail, edits } = input; + const stored = readAlignment(detail.longitudinal); + if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다. + const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null; + if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로. + + const alignment = buildAlignment(toAlignmentBase(stored), { + station_offsets: edits.station_offsets ?? {}, + curve_radii: edits.curve_radii ?? {}, + }); + // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 + // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 + // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 + // 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다** + // — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다. + detail.longitudinal.design_profiles = [ + toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), + ]; + + const rockOffsets = readRockOffsets(projectId, input.routeId); + const rockDefault = readRockBoundaryDefault(projectId); + // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 + // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). + const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 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 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ + const cutSlopeAt = (chainageM: number, design: Record): number | null => { + const session = cutSlopeRatios.get(rockKey(chainageM)); + if (session !== undefined) return session > 0 ? session : null; + const stored = design.cut_slope_ratio_user; + return typeof stored === "number" && stored > 0 ? stored : null; + }; + 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) { + const previous = section.design; + if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다. + const design = previous as unknown as Record; + // 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버 + // `recompute_designs_for_alignment` 의 우선순위와 같다. + const sessionOffset = rockOffsets.get(rockKey(section.chainage_m)); + const storedOffset = design.rock_boundary_offset_m; + const rockBoundaryOffsetM = + typeof sessionOffset === "number" + ? sessionOffset + : typeof storedOffset === "number" + ? storedOffset + : rockDefault; + const choice = choiceAt(section.chainage_m); + let next; + try { + next = computeCrossDesign( + section.samples ?? [], + planElevationAt(alignment, section.chainage_m), + { + 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: + 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, + cutSlopeRatio: cutSlopeAt(section.chainage_m, design), + twoStageSlope: + 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 { + continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도). + } + section.design = preserveUserFields( + next as unknown as NonNullable, + previous, + ); + updated.push(section.chainage_m); + } + return updated; +} + +/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */ +async function refreshFromServer(input: CrossRefreshInput): Promise { + const { projectId, routeId, detail, edits, shouldApply } = input; + const response = await previewCrossDesigns( + projectId, + routeId, + edits, + readStandardCrossSession(projectId) ?? undefined, + { + fullDesigns: true, + rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + berms: bermPayload(projectId, routeId, detail), + // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 + // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). + cutSlopeRatios: + readState>("cutslope", projectId, routeId) ?? undefined, + }, + ); + if (shouldApply && !shouldApply()) return []; + const designByChainage = new Map( + response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), + ); + const updated: number[] = []; + for (const section of detail.cross_sections) { + const next = designByChainage.get(section.chainage_m.toFixed(3)); + if (!next) continue; + section.design = preserveUserFields( + next as NonNullable, + section.design, + ); + updated.push(section.chainage_m); + } + return updated; +} diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index e7ada04d..af308b3c 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -1,5 +1,10 @@ """B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리. +⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠ + 짝: `common_util/common_util_cross_design_areas.ts` + 회귀 테스트: `tmp/tests/test_b06_cross_design_mirror.py` (고칠 때 같이 돌릴 것). + 두 벌인 이유는 `B06_Section_Engine_Design.py` 머리 참조. + `_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이 넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다. diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 43c39bca..865df085 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) @@ -431,7 +435,13 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] if abs(chainage - pipe_chainage) <= reach: # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. kind = spec.get("type") - section[_SECTION_KEYS.get(str(kind), "culvert")] = spec + # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 + # 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 + # **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리). + section[_SECTION_KEYS.get(str(kind), "culvert")] = { + **spec, + "chainage_m": pipe_chainage, + } attached += 1 break return attached diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 669e1eb1..1f26d92e 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -1,5 +1,17 @@ """B06 측점 표준횡단 설계 계산 엔진. +⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠ + 짝: `common_util/common_util_cross_design.ts` (`computeCrossDesign`) + 면적 적분은 `B06_Section_Engine_Areas.py` ↔ `common_util_cross_design_areas.ts`. + 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다: + `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것. + + 왜 두 벌인가: 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 + 한다**(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 서버에 전 측점 횡단을 물으면 + 왕복이 조작 속도를 지배했다. 이 파이썬 엔진은 자동설계 체인·[저장]·[확정]·도면 + 산출의 **정본**이고, TS 미러는 조작 중 화면용이다. 값이 갈리면 화면과 납품물이 + 달라지므로 새 필드·상수·판정을 더할 때 양쪽을 함께 고칠 것. + 지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단 설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가 버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서 @@ -15,6 +27,7 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ +import math from collections.abc import Callable from typing import Any @@ -22,13 +35,26 @@ 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, + fill_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 def _side_role(section_mode: str) -> tuple[str, str]: @@ -143,11 +169,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) @@ -161,7 +194,10 @@ 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]]] = {} + self._fill_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] = {} @@ -250,52 +286,115 @@ 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단계 무릎 반영). 지반 교차 클램프는 하지 않는다.""" + """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.cut_points(side), dist) + + def fill_points(self, side: str) -> list[tuple[float, float]]: + """성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다.""" + if side in self._fill_points_cache: + return self._fill_points_cache[side] 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 + points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm) + self._fill_points_cache[side] = points + return points + + def _fill_slope_z(self, side: str, dist: float) -> float: + """성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.fill_points(side), dist) + + def cut_slope_segments(self) -> list[dict[str, Any]]: + """절토 사면을 **경사 구간별로** 쪼갠 목록. + + ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고 + (암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈 + 위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** + + 원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는 + 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 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). @@ -365,7 +464,7 @@ class _SectionGeometry: max_dist = start_dist + 500.0 while dist <= max_dist: signed = dist if side == "left" else -dist - fill_line = start_z - (dist - start_dist) / self.fill_ratio + fill_line = self._fill_slope_z(side, dist) if fill_line - self._ground_at(signed) <= 0: result = dist break @@ -397,9 +496,7 @@ class _SectionGeometry: return z0 + (z1 - z0) * ratio return points[-1][1] role = self.left_role if side == "left" else self.right_role - start_dist, start_z = self._slope_start(side) dist = abs(offset_m) - run = dist - start_dist if role == "cut": # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). cross = self.cut_cross_dist(side) @@ -410,19 +507,34 @@ class _SectionGeometry: cross = self.fill_cross_dist(side) if cross is not None and dist >= cross: return ground_m - fill_line = start_z - run / self.fill_ratio - return max(fill_line, ground_m) + return max(self._fill_slope_z(side, dist), 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) + # 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. + if self.berm is not None: + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "fill": + continue + cross = self.fill_cross_dist(side) + for offset, _z in self.fill_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 @@ -432,6 +544,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, @@ -444,8 +572,13 @@ def compute_cross_design( standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = True, + cut_slope_ratio: float | None = None, 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]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -456,6 +589,12 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며 + None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시). + plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 + 입력이며, None·45m 이상이면 확폭이 없다. + curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 + (2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다. surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). @@ -475,6 +614,11 @@ def compute_cross_design( if ditch_type == "l_type" and preset_key != "rock": raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.") group = _resolve_group(preset_key, standard) + # 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + # ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 결과에 값만 + # 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + if cut_slope_ratio is not None and float(cut_slope_ratio) > 0: + group = {**group, "cut_slope_ratio": float(cut_slope_ratio)} paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] @@ -499,6 +643,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, @@ -511,6 +671,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, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -533,6 +696,12 @@ def compute_cross_design( # 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지). cut_area, fill_area = _trapezoid_areas(offsets, diffs) fill_ground_slope = geometry.fill_ground_slope() + # 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼 + # 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는 + # 지형이 있을 수 있으므로 계산은 손대지 않고 **경고만** 낸다(2026-09-03 사용자 확정). + slope_unclosed = bool(diffs) and ( + abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M + ) # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. @@ -592,10 +761,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), @@ -614,12 +792,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), @@ -629,16 +807,33 @@ def compute_cross_design( "cut_rock_area_m2": round(cut_rock_area, 4), "cut_rock_kind": cut_rock_kind, "fill_area_m2": round(fill_area, 4), + # 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용. + "slope_unclosed": slope_unclosed, # 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None. "fill_ground_slope": ( round(fill_ground_slope, 4) if fill_ground_slope is not None else None ), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, + # 절토 사면을 경사 구간별로 쪼갠 목록(소단 제외). + # ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 원래 임자였던 별표2 법정 경사 검사는 + # 폐기됐고(암질을 횡단도에서 안 고르기로 사용자 확정), 저장분(`cross_sections.data.design` + # 33키)에도 안 들어간다 — 저장되는 것은 화면이 보낸 설계 지정값이다. + # 그래도 남겨 두는 까닭: 소단 기하가 이 셈 위에 서 있고, 구간 경사를 아는 유일한 값이라 + # 뒤에 쓸 자리가 있다. **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** + "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..d4b3a70d --- /dev/null +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -0,0 +1,115 @@ +"""구조물 정본(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": "통나무·목재틀", +} + + +# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). +BERM_TYPE_ID = "berm" + + +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 + # 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + # 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + if structure.type_id == BERM_TYPE_ID: + 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.py b/B06_Section/B06_Section_Repository.py index f5f175d9..72807284 100644 --- a/B06_Section/B06_Section_Repository.py +++ b/B06_Section/B06_Section_Repository.py @@ -25,17 +25,18 @@ def _validate_stage_path(relative_path: str) -> str: return normalized.as_posix() -async def get_confirmed_route_context( - connection: aiomysql.Connection, project_id: UUID +async def _route_context( + connection: aiomysql.Connection, project_id: UUID, confirmed_only: bool ) -> dict[str, Any] | None: - """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다. + """경로 하나와 연결된 지표면 좌표계를 조회한다(정렬은 최신 우선). surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가 없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다. """ + status_filter = "AND r.status = 'CONFIRMED'" if confirmed_only else "" async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """ + f""" SELECT r.id AS route_id, COALESCE( sm.crs_epsg, @@ -47,7 +48,7 @@ async def get_confirmed_route_context( ) AS crs_epsg FROM routes r LEFT JOIN surface_models sm ON sm.id = r.surface_model_id - WHERE r.project_id = %s AND r.status = 'CONFIRMED' + WHERE r.project_id = %s {status_filter} ORDER BY r.computed_at DESC, r.id DESC LIMIT 1 """, @@ -62,6 +63,26 @@ async def get_confirmed_route_context( } +async def get_confirmed_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """최신 **확정** 경로 — 납품 도면(B07)처럼 확정본만 봐야 하는 곳이 쓴다.""" + return await _route_context(connection, project_id, confirmed_only=True) + + +async def get_workflow_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """워크플로 화면이 보는 경로 = **최신 경로**(확정 여부 무관). + + B05는 `get_latest_route()`로 최신 경로를 열고, B06은 확정 경로만 열어서 노선을 다시 + 탐색한 프로젝트에서 두 화면이 **다른 노선**을 봤다(2026-09-03 실측: B05 route 126 + DRAFT / B06 route 125 CONFIRMED — 같은 20m 측점의 성토가 4.82㎡ ↔ 85.9㎡). 같은 + 데이터를 두 창으로 보여 주는 구조이므로 경로 선택 규칙을 최신 경로 하나로 맞춘다. + """ + return await _route_context(connection, project_id, confirmed_only=False) + + async def get_latest_section_options( connection: aiomysql.Connection, project_id: UUID ) -> dict[str, Any] | None: 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 0ce22413..1c445cd2 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_confirmed_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,14 @@ 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, + stored_cut_slope, ) 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 +55,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,11 +74,10 @@ 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, @@ -101,18 +96,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: - route_context = await get_confirmed_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( @@ -131,6 +141,7 @@ 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, 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, @@ -257,6 +268,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} @@ -288,17 +302,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, @@ -311,27 +327,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"], @@ -488,8 +491,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"] ) @@ -514,7 +518,12 @@ async def preview_cross_designs( ) -> CrossDesignPreviewResponse | JSONResponse: """계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음). - B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다. + ⚠ **평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은 + 브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) — 조작 중 계산이 + 서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 이 엔드포인트는 + 선형 저장분이 없어 브라우저가 계획고를 풀 수 없는 **옛 데이터 폴백**으로만 남는다 + (`B06_Section_Cross_Refresh.refreshCrossDesigns`). + 영속화는 각 페이지의 임시저장·확정이 맡는다. """ pool = get_db_pool() @@ -546,6 +555,8 @@ async def preview_cross_designs( request.standard_cross_section, request.rock_boundary_offsets, project_root, + request.berms, + request.cut_slope_ratios, ) await asyncio.to_thread(rebuild) @@ -599,8 +610,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"]), @@ -617,35 +638,26 @@ async def compute_cross_section_design( standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, + # 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07). + cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}), 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 d563bb15..5f1aaf8a 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 @@ -29,6 +35,7 @@ PREVIEW_DESIGN_FIELDS = ( "fill_area_m2", "fill_ground_slope", "design_elevation_m", + "slope_unclosed", ) @@ -111,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", @@ -120,8 +135,14 @@ _USER_TOUCHED_KEYS = ( "ford_adjust", "box_adjust", "extra_wall_counts", + "extra_spans", "revet_link_detached", "revet_follow_grade", + # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", + # 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다 + # (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). + "cut_slope_ratio_user", ) @@ -175,12 +196,15 @@ def enforce_pavement_ranges( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), 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 @@ -225,12 +249,15 @@ def enforce_ford_surface_drops( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), 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 @@ -248,18 +275,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(): @@ -268,7 +314,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( @@ -297,6 +343,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 @@ -304,6 +351,33 @@ def attach_default_designs( continue +def stored_cut_slope(stored: dict[str, Any]) -> float | None: + """저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None. + + ⚠ 소단과 같은 성격이다 — **경사는 나르는 값이 아니라 기하 입력**이라 계산에 넣어야 한다. + 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛 경사로 나오고 값만 새것이 되어 어긋난다. + """ + value = stored.get("cut_slope_ratio_user") + if isinstance(value, (int, float)) and float(value) > 0: + return float(value) + return None + + +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]], @@ -311,6 +385,8 @@ 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, + cut_slope_ratios: dict[str, float] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -320,12 +396,30 @@ def recompute_designs_for_alignment( round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs } + # 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다. + session_cut_slopes: dict[float, float] = {} + for raw_key, ratio in (cut_slope_ratios or {}).items(): + try: + session_cut_slopes[round(float(raw_key), 3)] = float(ratio) + except (TypeError, ValueError): + continue session_offsets: dict[float, float] = {} for raw_key, offset in (rock_boundary_offsets or {}).items(): try: 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) @@ -346,8 +440,15 @@ def recompute_designs_for_alignment( stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), two_stage_slope=bool(stored.get("two_stage_slope", True)), + cut_slope_ratio=( + (session_cut_slopes[key] or None) + if key in session_cut_slopes + else stored_cut_slope(stored) + ), 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 @@ -407,6 +508,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..3cbfc1b2 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -52,6 +52,9 @@ class CrossDesignRequest(BaseModel): rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. two_stage_slope: bool = True + # 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # None 이면 표준 횡단면 설정값을 쓴다. + cut_slope_ratio: float | None = Field(default=None, gt=0) # 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override. ditch_enabled: bool | None = None # B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순. @@ -136,6 +139,10 @@ class CrossSectionPatch(BaseModel): rock_boundary_offset_m: float | None = None # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). display_half_width_m: float | None = Field(default=None, gt=0) + # 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야 + # 정본에 남은 옛 사용자 값이 지워진다. + cut_slope_ratio_user: float | None = Field(default=None, ge=0) inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None basin_adjust: BasinAdjustPatch | None = None # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). @@ -153,6 +160,25 @@ 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) + # 배수관 연장(m) — 브라우저 기하가 **m 단위 올림까지** 끝낸 값. 수량(B08)이 배수관 + # 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로 + # 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`). + pipe_length_m: float | None = Field(default=None, ge=0) class SectionConfirmRequest(BaseModel): @@ -223,6 +249,11 @@ 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 # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 @@ -278,6 +309,13 @@ 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 + # 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을 + # 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다. + cut_slope_ratios: 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..38e54977 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,53 @@ 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 + // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + // 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + .filter( + (type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm", + ) + .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 +98,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 +128,7 @@ export function replaceSectionDetail( detail: SectionDetailResponse, ): void { cache.set(keyOf(projectId, routeId), detail); + writeState("section-detail", detail, projectId, routeId); } /** @@ -69,11 +138,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 +165,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..399ecd57 --- /dev/null +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -0,0 +1,209 @@ +"""브라우저에서만 돌던 횡단 계산을 **서버가** 돌려 정본에 남긴다(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 가 다른 키를 내도 설계 데이터에 흘리지 않는다. +# ⚠ **TS 쪽 `STRUCTURE_ROW_KEYS` 와 짝이다.** 한쪽만 늘리면 Node 가 값을 내도 여기서 조용히 +# 버려진다(2026-09-08 관 길이를 더하며 실제로 걸린 자리). 시험이 두 목록을 대조한다. +_AREA_KEYS = ( + "cut_area_m2", + "fill_area_m2", + "cut_soil_area_m2", + "cut_rock_area_m2", + "pipe_length_m", +) + + +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..8a61f7b8 --- /dev/null +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -0,0 +1,204 @@ +/* ============================================================================= + * 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 + ); +} + +/** + * 정본에 얹는 키 — 구조물이 선 측점에서만 나오는 값들. + * + * 면적 넷에 **관 길이**를 더했다(2026-09-08). 관 길이는 브라우저 기하가 **m 단위 올림까지** + * 끝낸 값인데 정본에 없어 **수량이 배수관 연장을 못 냈다**(B08 창 보고). 계산을 서버로 + * 옮기거나 새로 짜지 않고, **이미 서버가 Node 로 돌리는 이 다리에 한 줄 더 실었다** + * (CLAUDE.md 5장 — 계산은 한 벌). + */ +export const STRUCTURE_ROW_KEYS = [ + "cut_area_m2", + "fill_area_m2", + "cut_soil_area_m2", + "cut_rock_area_m2", + "pipe_length_m", +] as const; + +/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */ +function areaRowOf( + section: CrossSection, + sections: readonly CrossSection[], +): Record | null { + const layouts = computeStoredLayouts(section, sections); + if (!layouts) return null; + // 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도 + // 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08). + // + // ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만 + // (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번** + // 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다. + const pipeOwner = + !!section.culvert && + (typeof section.culvert.chainage_m !== "number" || + Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M); + const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined; + const pipeRow: Record | null = + typeof pipeLengthM === "number" && pipeLengthM > 0 + ? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) } + : null; + const trim = trimOfLayouts(layouts); + const design = layouts.design; + if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow; + 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 pipeRow; + const round = (value: number): number => Number(value.toFixed(4)); + const row: Record = { + ...(pipeRow ?? {}), + 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_ROW_KEYS) { + if (typeof row[key] === "number") design[key] = row[key]; + } + } +} diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts new file mode 100644 index 00000000..b6e1b8d5 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Card_Chrome.ts + * 횡단 카드의 **껍데기**(제목행·하단 정보행)만 떼어 낸 조립기 (2026-09-03 · 700줄 제한). + * + * 카드 본체(`_UI_Cross_View`)는 SVG 도면과 조작을 맡고, 도면과 무관한 머리·꼬리는 여기서 + * 만든다. 여기 있는 것은 전부 **읽어서 붙이는 값**이라 카드 상태(줌·선택)를 건드리지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; +import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; +import { + buildDesignControls, + buildRockBoundaryControl, + sectionModeLabel, +} from "./B06_Section_UI_Cross_Design"; +import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; +import { + type DesignChangeHandler, + L, + stationLabel, + structureDisplayName, +} from "./B06_Section_UI_Section_Common"; + +/** + * 성토사면 경사길이 표기 칸 — 성토측이 없으면 null. + * + * 값은 **구조물이 끊기 전 본래 사면**(정본 설계선 기준)이라, 기슭막이·집수정이 선 측점도 + * 표준 횡단의 사면 길이를 그대로 보여 준다(2026-09-03 사용자 지시). + */ +function fillSlopeInfo(section: CrossSection): HTMLElement | null { + const lengths = fillSlopeLengths(section); + const sides = (["left", "right"] as const).filter((side) => lengths[side] !== null); + if (!sides.length) return null; + const both = sides.length > 1; + const info = document.createElement("span"); + info.className = "b06-cross-card__fillslope"; + info.textContent = `${L("B06_Cross_FillSlope")} ${sides + .map((side) => { + const length = lengths[side] as FillSlopeLength; + // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. + const value = `${length.open ? "≥" : ""}${length.lengthM.toFixed(2)}m`; + if (!both) return value; + const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); + return `${label} ${value}`; + }) + .join(" · ")}`; + info.title = L("B06_Cross_FillSlope_Tip"); + return info; +} + +/** + * 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지). + * + * 1행 구조(E-2/E-3): 측점 위치표기 → 지반유형 → 단면유형 pill → 구조물 → 측점정보. + */ +export function appendCardHeader( + card: HTMLElement, + section: CrossSection, + stationInterval: number, + onDesignChange?: DesignChangeHandler, +): void { + const header = document.createElement("header"); + const title = document.createElement("div"); + title.className = "b06-cross-card__title"; + const label = document.createElement("strong"); + label.textContent = stationLabel(section.chainage_m, stationInterval); + const chainage = document.createElement("span"); + chainage.textContent = `${section.chainage_m.toFixed(1)}m`; + title.append(label, chainage); + + // 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3). + const modePill = document.createElement("span"); + modePill.className = "b06-cross-card__mode"; + modePill.textContent = sectionModeLabel(section.design?.section_mode); + modePill.title = L("B06_Design_Mode_Legend"); + + const meta = document.createElement("div"); + meta.className = "b06-cross-card__meta"; + // 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지 + // 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정). + if (section.design?.slope_unclosed) { + const openSlope = document.createElement("span"); + openSlope.className = "b06-cross-card__warning"; + openSlope.textContent = `⚠ ${L("B06_Cross_SlopeUnclosed")}`; + openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); + meta.append(openSlope); + } + const structureName = section.structure; + if (structureName) { + const structure = document.createElement("span"); + structure.className = "b06-cross-card__structure"; + // 배수관은 관종·관경(`파형강관 D1200`)이 아니라 표시 이름 하나로 적는다 + // (2026-09-03 사용자 지시). 관종·관경은 툴팁에 남긴다. + structure.textContent = structureDisplayName(structureName); + structure.title = `구조물: ${structureName}`; + meta.append(structure); + } + // 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시). + if (section.kind === "bp" || section.kind === "ep") { + const kind = document.createElement("span"); + kind.textContent = L( + section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP", + ); + meta.append(kind); + } + // 성토사면 경사길이는 **행 우측 끝**(2026-09-03 사용자 지시) — meta 가 제목행의 오른쪽 + // 끝이므로 그 마지막 칸에 붙인다. + const fillSlope = fillSlopeInfo(section); + if (fillSlope) meta.append(fillSlope); + + // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). + modePill.classList.add("b06-cross-card__mode--right"); + header.append(title); + if (onDesignChange) { + const controls = buildDesignControls(section, onDesignChange); + controls.groundSegment.classList.add("b06-cross-card__ground"); + header.append(controls.groundSegment, modePill, meta); + card.append(header, controls.bar); + return; + } + header.append(modePill, meta); + card.append(header); +} + +/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */ +export function appendCardFooter( + card: HTMLElement, + section: CrossSection, + rockBoundary?: RockBoundaryControl, + cutSlope?: CutSlopeControl, +): void { + const footer = document.createElement("footer"); + const center = document.createElement("span"); + center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; + footer.append(center); + // 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만. + if (rockBoundary && section.design?.geometry_preset === "rock") { + const rockControl = buildRockBoundaryControl(section, rockBoundary); + rockControl.classList.add("b06-cross-card__rockb"); + footer.append(rockControl); + } + // 암 절토 경사(각도)는 암 측점에만 — 토사 측점은 암반이 없어 쓰이지 않는다 + // (2026-09-07 사용자 확정). 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이다. + if (cutSlope && section.design?.geometry_preset === "rock") { + const slopeControl = buildCutSlopeControl(section, cutSlope); + slopeControl.classList.add("b06-cross-card__cutslope"); + footer.append(slopeControl); + } + card.append(footer); +} 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_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index d789323a..744c21cb 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -174,21 +174,15 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { }; // I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자 // 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥. - const wallBase = anchor.offset + outward * (lean * wallHeight); + const wallBaseAt = (height: number): number => anchor.offset + outward * (lean * height); // I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼 // 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지. - const iWallBottom = - shape === "I" - ? Math.min(anchor.elevation, groundAt(wallBase), groundAt(wallBase + outward * memberT)) - - floorThickness - : defaultBottom; - const parts: BasinLayout["parts"] = [ - { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, - ]; - const iWallPoints = parts[0].points; - const iGroundIntersection = (): OffsetPoint => { - const low = iWallPoints[3]; - const high = iWallPoints[2]; + const iBottomAt = (base: number): number => + Math.min(anchor.elevation, groundAt(base), groundAt(base + outward * memberT)) - floorThickness; + /** 벽 계류측 변(하단-전면 → 상단-전면)과 원지반의 교점 — I형 관 하단 자리. */ + const groundOnFace = (points: OffsetPoint[]): OffsetPoint => { + const low = points[3]; + const high = points[2]; let lo = 0; let hi = 1; for (let pass = 0; pass < 32; pass += 1) { @@ -206,6 +200,34 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { elevation: low.elevation + (high.elevation - low.elevation) * t, }; }; + const wallBase = wallBaseAt(wallHeight); + const iWallBottom = shape === "I" ? iBottomAt(wallBase) : defaultBottom; + const parts: BasinLayout["parts"] = [ + { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, + ]; + const iWallPoints = parts[0].points; + /** 벽 계류측 변 위에서 그 표고에 해당하는 점 — 관 하단을 벽면을 따라 내릴 때 쓴다. */ + const pointOnFaceAt = (elevation: number): OffsetPoint => { + const low = iWallPoints[3]; + const high = iWallPoints[2]; + const span = high.elevation - low.elevation; + const t = span > 1e-9 ? Math.min(Math.max((elevation - low.elevation) / span, 0), 1) : 0; + return { + offset: low.offset + (high.offset - low.offset) * t, + elevation: low.elevation + span * t, + }; + }; + /** + * I형 관 하단 자리 = 벽 계류측 변과 원지반의 교점. 다만 **관이 벽을 관통해야** 하므로 + * (ㄴ·ㄷ형은 바닥판 위에서 이미 물린다) 벽 상단에서 관경+여유만큼 아래로 제한한다. + * 원지반이 벽 상단보다 높은 자리에서 제한이 없으면 관이 벽 위로 떠 벽과 만나지 않았다 + * (2026-09-03 사용자 지적: 「측벽과 배관의 형상이 교차하고 있어야 함」). + */ + const iGroundIntersection = (): OffsetPoint => { + const hit = groundOnFace(iWallPoints); + const invertCeiling = anchor.elevation + wallHeight - (diameterM + REVET_FREEBOARD_M); + return hit.elevation <= invertCeiling ? hit : pointOnFaceAt(invertCeiling); + }; // ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다 // (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다. const wallOuterAt = (elevation: number): number => { 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_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 6ebc313a..500d1d40 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -561,7 +561,17 @@ export function pipeAxisSolver( Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) > STRAY_LIMIT_M; if (strayed) return fallback; - return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) }; + const cutBottom = clampToFace(bottom, face); + const cutTop = clampToFace(top, face); + // 마감면을 비스듬히 자르면 끝단 길이는 관경 이상이다. 그보다 짧으면 선분 밖으로 + // 나간 교점이 한 끝점으로 접힌 것 — I형 벽이 원지반에 묻혀 관 시작점이 벽 상단에 + // 붙으면 두 꼭짓점이 한 점이 되어 관이 삼각형으로 그려진다. 그럴 땐 축 직각 마감으로. + const cutSpan = Math.hypot( + cutTop.offset - cutBottom.offset, + cutTop.elevation - cutBottom.elevation, + ); + if (cutSpan < diameter - 1e-6) return fallback; + return { bottom: cutBottom, top: cutTop }; }; solver.derive(); return solver; 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_CutSlope.ts b/B06_Section/B06_Section_UI_Cross_CutSlope.ts new file mode 100644 index 00000000..23d1f842 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_CutSlope.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * B06_Section_UI_Cross_CutSlope.ts + * 측점 하나의 **암 절토 경사** 입력칸(2026-09-07 사용자 지시). + * + * 사용자 원문 — 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는 + * 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점 + * 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다). + * + * 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의 + * 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + * + * ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은 + * 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다 + * (`computeCrossDesign(cutSlopeRatio)` · `compute_cross_design(cut_slope_ratio=…)`). + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +/** 측점별 암 절토 경사 제어기 — Page 가 세션 저장소와 연결해 구현한다. */ +export interface CutSlopeControl { + /** 세션 → design 저장값 → 표준값 순으로 지금 경사비를 돌려준다. */ + ratioFor: (section: CrossSection) => number; + /** 이 측점의 표준값(되돌릴 자리) — 사용자가 안 만진 상태의 값. */ + standardRatioFor: (section: CrossSection) => number; + /** 경사비를 넣는다. null 이면 표준값으로 되돌린다. */ + set: (chainageM: number, ratio: number | null) => void; +} + +/** 경사비(1:n 의 n) → 수평에서 잰 각도(°). n 이 작을수록 급하다. */ +export function ratioToDegrees(ratio: number): number { + return (Math.atan(1 / Math.max(ratio, 1e-6)) * 180) / Math.PI; +} + +/** 각도(°) → 경사비(1:n 의 n). 1~89° 밖은 사면이 서지 않아 잘라 받는다. */ +export function degreesToRatio(degrees: number): number { + const clamped = Math.min(Math.max(degrees, 1), 89); + return 1 / Math.tan((clamped * Math.PI) / 180); +} + +/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */ +function sameDegrees(a: number, b: number): boolean { + return Math.abs(a - b) < 0.05; +} + +/** + * 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다. + * + * 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도 + * 따라간다(사용자가 만진 흔적이 지워지므로). + */ +export function buildCutSlopeControl(section: CrossSection, control: CutSlopeControl): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b06-design__seg b06-design__cutslope"; + const legend = document.createElement("span"); + legend.className = "b06-design__seg-legend"; + legend.textContent = "암 절토"; + wrap.append(legend); + + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + const ratio = control.ratioFor(section); + const standard = control.standardRatioFor(section); + const degrees = ratioToDegrees(ratio); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "b06-design__cutslope-input"; + input.step = "0.5"; + input.min = "1"; + input.max = "89"; + input.value = degrees.toFixed(1); + input.title = + `이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` + + `지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` + + `전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`; + const commit = (): void => { + const entered = Number(input.value); + if (!Number.isFinite(entered)) { + input.value = degrees.toFixed(1); + return; + } + // 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다. + const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered); + control.set(section.chainage_m, next); + }; + input.addEventListener("change", commit); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + }); + // 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도. + input.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const unit = document.createElement("span"); + unit.className = "b06-design__cutslope-unit"; + unit.textContent = "°"; + + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b06-design__rockb-btn is-reset"; + reset.textContent = "↺"; + reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`; + reset.disabled = sameDegrees(degrees, ratioToDegrees(standard)); + reset.addEventListener("click", (event) => { + event.stopPropagation(); + control.set(section.chainage_m, null); + }); + + group.append(input, unit, reset); + wrap.append(group); + return wrap; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 6e0349aa..e6cc50f5 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -31,15 +31,57 @@ 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]; } -const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [ - ["soil", "B06_Design_Ground_Soil"], - ["ripping_rock", "B06_Design_Ground_Ripping"], - ["blasting_rock", "B06_Design_Ground_Blasting"], -]; +/** + * 지반유형은 **「토사」 토글 하나**다(2026-09-07 사용자 확정) — 켜면 토사, **끄면 암**(기본). + * + * 사용자 원문 — 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 + * 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화)」. 까닭은 **암반 지정·범위가 + * 실무에서 애매해** 측점마다 암질을 못 박는 것이 오히려 틀리기 때문이고, 연암:경암 · + * 발파암:리핑암은 **설계내역에서 설계자가 비율로** 넣기로 했다(계획서 8-1). + * + * ⚠ 저장값은 종전 그대로 쓴다 — 끔 = `ripping_rock`, 켬 = `soil`. 옛 자료의 `blasting_rock` + * 도 「암(끔)」으로 읽힌다. 유토곡선·수량은 암을 한 종류(리핑암)로 잡으며, 갈라 넣는 것은 + * 설계내역 몫이다. + */ +const GROUND_ROCK_DEFAULT: GroundType = "ripping_rock"; const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [ ["left_cut", "B06_Design_Mode_LeftCut"], ["right_cut", "B06_Design_Mode_RightCut"], @@ -287,7 +329,8 @@ export function buildDesignControls( twoStage: boolean; ditchEnabled: boolean | null; } = { - ground: design?.ground_type ?? "soil", + // 설계가 아직 없는 측점의 기본은 **암**이다(2026-09-07 사용자: 「기본값은 비활성화」). + ground: design?.ground_type ?? GROUND_ROCK_DEFAULT, mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"), ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", @@ -319,11 +362,24 @@ export function buildDesignControls( }); }; - // 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만. - const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => { - state.ground = value; - emit(); - }); + // 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07). + // 버튼명은 「토사」로 고정하고 **켜짐/꺼짐(색)**으로 상태를 보인다 — 측구 토글과 같은 꼴 + // (사용자 원문: 「토사버튼만 존재하고 이값은 활성화/비활성화로 반영」). + const groundToggle = toggle( + "", + state.ground === "soil", + L("B06_Design_Ground_Soil"), + L("B06_Design_Ground_Soil"), + () => { + state.ground = state.ground === "soil" ? GROUND_ROCK_DEFAULT : "soil"; + emit(); + }, + ); + groundToggle.button.title = + state.ground === "soil" + ? "토사 — 암반이 없어 절토는 토사 경사 하나로만 그린다. 누르면 암으로 바뀐다." + : "암 — 암 경계선이 서고 그 아래는 암 절토각, 위는 토사 경사로 그린다. 누르면 토사로 바뀐다."; + const groundSegment = groundToggle.wrap; groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). @@ -412,24 +468,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 +731,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_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index 3814d143..b5d09f6c 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -58,6 +58,110 @@ export function toeFitHalfWidth(section: CrossSection): number | null { return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); } +/** + * 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시). + * + * 「본래」 = 구조물(기슭막이·집수정)이 사면을 끊기 전 표준 횡단 기준. 구조물은 프론트 + * 오버레이라 백엔드 `design_line`을 바꾸지 않으므로, 그 선으로 재면 곧 본래 길이다. + * 구간은 노견 끝(그 측에 측구가 있으면 측구 바깥)부터 설계선이 원지반과 처음 만나는 + * 점까지이고, 그 사이 성토선은 1:n 직선이라 수평 성분만으로 경사길이가 나온다. + */ +export interface FillSlopeLength { + /** 사면 경사길이(m). 미교차(`open`)면 계산 반폭까지의 **하한값**이다. */ + lengthM: number; + /** 설계선 끝(계산 반폭)까지 원지반과 만나지 못한 사면 — 길이를 다 재지 못했다. */ + open: boolean; +} + +export function fillSlopeLengths(section: CrossSection): { + left: FillSlopeLength | null; + right: FillSlopeLength | null; +} { + const lengths: { left: FillSlopeLength | null; right: FillSlopeLength | null } = { + left: null, + right: null, + }; + const design = section.design; + if (!design) return lengths; + const groundAt = groundInterpolator(section.samples); + const designAt = designInterpolator(design.design_line); + const edges = design.road_edges; + if (!groundAt || !designAt || !edges) return lengths; + const lineOffsets = design.design_line.map((point) => point.offset_m); + if (lineOffsets.length < 2) return lengths; + const { protectMax, protectMin } = protectedSpan(design, edges); + const slant = Math.hypot(1, 1 / Math.max(design.fill_slope_ratio, 1e-6)); + for (const side of ["left", "right"] as const) { + const mode = design.section_mode; + if (mode === "both_cut" || mode === `${side}_cut`) continue; + const outward = side === "left" ? 1 : -1; + const start = side === "left" ? protectMax : protectMin; + const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets); + // 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가 + // 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다 + // (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지). + const meet = meetOffset(designAt, groundAt, start, limit, outward); + const end = Math.abs(meet) > Math.abs(limit) ? limit : meet; + lengths[side] = { + // 소단이 있으면 사면이 계단으로 끊기므로 **구간별 최대**를 잰다. 없으면 종전대로 + // 수평 성분 × 기울기 — 값이 한 톨도 안 바뀐다(2026-09-07, 계획서 3-9). + lengthM: design.berm + ? longestFillRun(design, start, end, slant) + : Math.abs(end - start) * slant, + open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M, + }; + } + return lengths; +} + +/** + * 소단으로 끊긴 성토사면에서 **가장 긴 한 구간**의 경사길이(m). + * + * 왜 최대인가 — 법정 기준은 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」다 + * (`성토_비탈면.md` §2). 소단은 사면을 끊는 시설이므로, 판정은 **끊긴 한 도막**을 봐야 한다. + * 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않고**, 반대로 실효 경사로 + * 재면 완만해져 **의무가 사라진 것처럼** 보인다. 둘 다 틀린다. + * + * 평탄부(소단)는 길이에 안 넣고 그 자리에서 도막을 끊는다. + */ +function longestFillRun( + design: CrossDesign, + startOffset: number, + endOffset: number, + slant: number, +): number { + const berm = design.berm; + if (!berm) return Math.abs(endOffset - startOffset) * slant; + const low = Math.min(startOffset, endOffset); + const high = Math.max(startOffset, endOffset); + // 소단인지는 **기울기**로 가른다 — 폭으로 찾으면 못 찾는다. 설계선 꼭짓점에는 지반 + // 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 두 도막으로 **쪼개지기** 때문이다 + // (2026-09-07 실측: 폭으로 찾으니 평탄부 0개). 소단 기울기는 2°(0.035)이고 성토 기울기는 + // 1:1.2~2.0(0.5~0.83)이라 절반만 잡아도 둘이 확실히 갈린다. + const fillGradient = 1 / Math.max(design.fill_slope_ratio, 1e-6); + const bermMaxGradient = fillGradient * 0.5; + let longest = 0; + let current = 0; + const line = design.design_line; + for (let index = 1; index < line.length; index += 1) { + const a = line[index - 1]; + const b = line[index]; + const from = Math.max(Math.min(a.offset_m, b.offset_m), low); + const to = Math.min(Math.max(a.offset_m, b.offset_m), high); + if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다 + const run = Math.abs(b.offset_m - a.offset_m); + if (run <= 1e-9) continue; + const gradient = Math.abs(b.elevation_m - a.elevation_m) / run; + if (gradient < bermMaxGradient) { + longest = Math.max(longest, current); // 소단에서 도막이 끊긴다 + current = 0; + continue; + } + current += (to - from) * slant; + } + return Math.max(longest, current); +} + /** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */ function protectedSpan( design: CrossDesign, @@ -98,7 +202,12 @@ function meetOffset( const offset = startOffset + outward * Math.min(SCAN_STEP_M * index, span); const diff = designAt(offset) - groundAt(offset); if (Math.abs(diff) <= MEET_TOLERANCE_M) return offset; - if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset; + if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) { + // 두 걸음 사이를 선형보간한다 — 반폭 맞춤에는 영향이 없지만(1m 올림), 같은 교차점을 + // 길이로 읽는 성토사면 표기가 탐색 간격(0.1m)만큼 튀는 것을 막는다(2026-09-03). + const previous = offset - outward * SCAN_STEP_M; + return previous + (offset - previous) * (previousDiff / (previousDiff - diff)); + } previousDiff = diff; } return extrapolateMeet(designAt, groundAt, limitOffset, outward); 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 36b0188a..b5c18b79 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -23,11 +23,9 @@ import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, - buildDesignControls, - buildRockBoundaryControl, - sectionModeLabel, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; +import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert"; import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; @@ -35,7 +33,8 @@ 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 { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; +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 { ExtraWallControl, @@ -47,8 +46,9 @@ import type { import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; -import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; -import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; +import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; @@ -60,13 +60,16 @@ import { type DesignChangeHandler, emptyView, L, - stationLabel, svgElement, svgText, validElevation, } from "./B06_Section_UI_Section_Common"; -import { crossPlotMetrics } 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"; /** * 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다 @@ -113,9 +116,6 @@ export type { StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; -/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */ -const cardZoomStates = new Map(); - export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -149,29 +149,19 @@ export function createCrossSectionCard( /** 세월교 측벽·BOX암거 구체 조작 제어(2026-08-25). */ ford?: FordControl, box?: BoxControl, + /** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */ + plotBase?: CrossPlotBase | null, + /** 측점별 암 절토 경사 입력(2026-09-07) — 암 측점 하단 정보행에 선다. */ + cutSlope?: CutSlopeControl, ): CrossCardElement { // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 // (2026-08-24 사용자). 길이·전/후 같은 구간 값은 소유 측점 한 곳에서 관리한다. const isLinkedCulvert = !section.culvert && !!culvertLink; - // 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). 절·성토선이 원지반과 만나는 지점 - // (교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다(2026-08-22, 판정 기준 개편 - // 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점 = toeFitHalfWidth). - // 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우 - // [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다. - const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; - const sampledExtent = Math.max( - 0, - ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + const effectiveHalfWidth = effectiveCardHalfWidth( + section, + stationWidth?.widthFor(section) ?? crossHalfWidth, ); - const requiredHalfWidth = Math.min( - Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0), - sampledExtent, - ); - const effectiveHalfWidth = - baseHalfWidth !== undefined && requiredHalfWidth > 0 - ? Math.max(baseHalfWidth, requiredHalfWidth) - : baseHalfWidth; const card: CrossCardElement = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; @@ -291,63 +281,20 @@ export function createCrossSectionCard( onAreaSelect?.(section.station_id, activeArea); }; - // 제목행 1행 구조(E-2/E-3): 측점 위치표기 → 단면유형 pill → 지반유형 → 구조물 → 측점정보. - const header = document.createElement("header"); - const title = document.createElement("div"); - title.className = "b06-cross-card__title"; - const label = document.createElement("strong"); - label.textContent = stationLabel(section.chainage_m, stationInterval); - const chainage = document.createElement("span"); - chainage.textContent = `${section.chainage_m.toFixed(1)}m`; - title.append(label, chainage); + appendCardHeader(card, section, stationInterval, onDesignChange); - // 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3). - const modePill = document.createElement("span"); - modePill.className = "b06-cross-card__mode"; - modePill.textContent = sectionModeLabel(section.design?.section_mode); - modePill.title = L("B06_Design_Mode_Legend"); - - const meta = document.createElement("div"); - meta.className = "b06-cross-card__meta"; - if (section.structure) { - const structure = document.createElement("span"); - structure.className = "b06-cross-card__structure"; - structure.textContent = section.structure; - structure.title = `구조물: ${section.structure}`; - meta.append(structure); - } - // 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시). - if (section.kind === "bp" || section.kind === "ep") { - const kind = document.createElement("span"); - kind.textContent = L( - section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP", - ); - meta.append(kind); - } - - // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). - modePill.classList.add("b06-cross-card__mode--right"); - header.append(title); - if (onDesignChange) { - // 제목행: 측점 라벨 → (구분선) 지반유형 → 단면유형 pill(우측) → 구조물·kind (D-6/E-2/1·3번). - const controls = buildDesignControls(section, onDesignChange); - controls.groundSegment.classList.add("b06-cross-card__ground"); - header.append(controls.groundSegment, modePill, meta); - card.append(header); - card.append(controls.bar); - } else { - header.append(modePill, meta); - card.append(header); - } - - 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 { @@ -504,17 +451,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( @@ -705,6 +651,7 @@ export function createCrossSectionCard( adjustOf, outwardOf, heightOfWall, + drawnWallKeys: () => [...culvertWallSpecs.keys()], formOfWall, appliedD: () => culvertAppliedD, pipeLengthM: () => culvertPipeLengthM, @@ -760,16 +707,40 @@ export function createCrossSectionCard( card.append(chartWrap); } - const footer = document.createElement("footer"); - const center = document.createElement("span"); - center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; - footer.append(center); - // 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만. - if (rockBoundary && section.design?.geometry_preset === "rock") { - const rockControl = buildRockBoundaryControl(section, rockBoundary); - rockControl.classList.add("b06-cross-card__rockb"); - footer.append(rockControl); - } - card.append(footer); + appendCardFooter(card, section, rockBoundary, cutSlope); 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 04cdd36b..03c8a8a5 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -1,9 +1,11 @@ import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common"; +import { culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; +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; @@ -14,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, @@ -46,30 +62,67 @@ 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; +} + +/** + * 카드가 실제로 그릴 표시 반폭 — 개별값 > 전역값(2026-08-06). + * + * 절·성토선이 원지반과 만나는 지점(교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다 + * (2026-08-22, 판정 기준 개편 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점). + * 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우 + * [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다. + */ +export function effectiveCardHalfWidth( + section: CrossSection, + baseHalfWidth: number | undefined, +): number | undefined { + const sampledExtent = Math.max( + 0, + ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + ); + const requiredHalfWidth = Math.min( + Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0), + sampledExtent, + ); + return baseHalfWidth !== undefined && requiredHalfWidth > 0 + ? Math.max(baseHalfWidth, requiredHalfWidth) + : baseHalfWidth; } 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_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts index 271cd768..9ccb9de7 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -53,6 +53,9 @@ export interface ContentBounds { height: number; } +/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */ +export const cardZoomStates = new Map(); + export function attachZoomPan( svg: SVGSVGElement, plotLayer: SVGGElement, diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 4599b034..3026c01f 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -17,6 +17,7 @@ import { longitudinalMaxChainage, niceTickStep, stationLabel, + structureDisplayName, svgElement, svgText, validElevation, @@ -24,6 +25,63 @@ import { } from "./B06_Section_UI_Section_Common"; /** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */ +/** + * 세로 창 변환 규약 — 그리는 쪽(이 파일)과 갱신하는 쪽(B05 `_UI_Profile_YWindow`)이 함께 쓴다. + * + * 세로 위치를 좌표에 박아 두면 창이 바뀔 때마다 그래프를 통째로 다시 만들어야 한다 + * (실측 34ms · 60Hz 두 프레임). 세로에 딸린 도형만 한 겹으로 묶어 두면 **변환 한 줄**로 + * 창을 옮길 수 있다(2026-09-04 사용자 확정). + */ +/** 자동 세로 창의 여유 — 최고·최저가 축선에 붙지 않게 위아래로 10%씩(2026-08-23 사용자). */ +export const Y_WINDOW_SPAN_PAD = 1.2; +/** 세로 창 변환을 받는 겹 — 세로에 딸린 **도형만** 들어간다(글자는 늘어나므로 제외). */ +export const Y_WINDOW_CLASS = "b06-chart__ywindow"; +/** 눈금선 겹·눈금 글자 겹 — 창이 크게 바뀌면 이 둘만 다시 만든다(그래프는 그대로). */ +export const Y_GRID_CLASS = "b06-chart__ygrid"; +export const Y_TICK_CLASS = "b06-chart__yticks"; +/** 글자가 그릴 때의 y를 남기는 속성 — 변환 대신 이 값으로 자리만 옮긴다. */ +export const Y_WINDOW_BAKED_ATTR = "data-yw-y"; +/** 바깥에서 새 창의 변환을 계산하는 데 필요한 기준값(SVG 속성 이름). */ +export const Y_WINDOW_ATTRS = { + /** "1"이면 자동 세로 창(B05) — 고정 배율(B06)은 갱신 대상이 아니다. */ + auto: "data-yw-auto", + /** 그릴 때 쓴 창 중심 표고(m). */ + center: "data-yw-center", + /** 표고 1m당 화면 px. */ + pxPerM: "data-yw-pxm", + /** 창 중심 표고가 놓인 y(px). */ + zero: "data-yw-y0", + /** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */ + top: "data-yw-top", + height: "data-yw-height", + /** 세로 과장(B06 조절값). 새 창의 px 환산에 그대로 곱한다. */ + exaggeration: "data-yw-exag", +} as const; + +/** + * 세로 눈금값 목록 — 창(중심·폭)에 대해 1·2·5 계열의 딱 떨어지는 표고를 고른다. + * 라벨 글자(최대 13px)가 겹치지 않게 눈금 간격은 16px 이상, 표고 눈금은 1m 아래로 + * 내려가지 않는다(2026-08-23 사용자 지시). + * + * 그릴 때(이 파일)와 창이 바뀌어 눈금만 다시 만들 때(B05 `_UI_Profile_YWindow`)가 + * **같은 눈금**을 써야 해서 여기로 뺐다(2026-09-04). + */ +export function yWindowTickValues( + center: number, + span: number, + plotHeight: number, +): Array<{ value: number; label: string }> { + const step = Math.max(niceTickStep(span, 10, Math.floor(plotHeight / 16)), 1); + const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9)); + const ticks: Array<{ value: number; label: string }> = []; + const topValue = center + span / 2; + for (let value = Math.ceil((center - span / 2) / step) * step; value <= topValue + 1e-9;) { + ticks.push({ value, label: `${value.toFixed(decimals)}m` }); + value += step; + } + return ticks; +} + function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string { const base = stationLabel(chainageM, interval); if (!stationOffset) return base; @@ -49,7 +107,7 @@ export function longitudinalMinimumWidth( * 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다. */ function appendCutFillBands( - svg: SVGSVGElement, + svg: SVGElement, profile: DesignProfile, x: (chainage: number) => number, planY: (index: number) => number, @@ -167,6 +225,18 @@ export function createLongitudinalProfile( * LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다. */ bottomInsetPx = 0, + /** + * 세로 표시창의 중심을 위·아래로 옮기는 몫 — **창 높이 대비 비율**(0 = 현행 가운데, + * +0.1 = 창 높이의 10%만큼 위쪽을 본다). B05 종단도의 Y 레인지 조작구가 쓴다. + * 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04). + */ + elevationOffsetRatio = 0, + /** + * **보이는 구간의 표고 범위**(자동 세로 맞춤, 2026-09-04 사용자 확정). 넘기면 전 구간 + * 최저~최고 대신 이 범위로 Y 창을 잡는다 — 가로로 확대했을 때 그 구간의 고저차가 + * 화면 높이를 채운다. 공통 Y 스케일(`yScaleOptions`)이 있으면 그쪽이 우선이다. + */ + elevationRange?: { min: number; max: number }, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -191,8 +261,10 @@ export function createLongitudinalProfile( const elevations = samples .map((sample) => sample.elevation_m) .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); - const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); - const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const rawMin = + yScaleOptions?.globalMinElevation ?? elevationRange?.min ?? Math.min(...elevations); + const rawMax = + yScaleOptions?.globalMaxElevation ?? elevationRange?.max ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); // 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아, @@ -205,33 +277,62 @@ export function createLongitudinalProfile( ? plotHeight / yScaleOptions.pixelsPerMeter : // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다 // (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다. - Math.max(rawMax - rawMin, 1) * 1.2; + Math.max(rawMax - rawMin, 1) * Y_WINDOW_SPAN_PAD; + // 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다. + const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration); const x = (chainage: number) => LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth; const xInverse = inverseOf(x, maxChainage); const y = (elevation: number) => - LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + // 세로 창 갱신에 필요한 기준값을 SVG에 남긴다 — 그리는 함수의 인자를 늘리지 않으려는 것. + // 매핑은 1차식이다: y = zero − (표고 − center) × pxPerM. + svg.setAttribute(Y_WINDOW_ATTRS.center, String(viewCenter)); + svg.setAttribute(Y_WINDOW_ATTRS.pxPerM, String((exaggeration * plotHeight) / elevationSpan)); + svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2)); + svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top)); + svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight)); + svg.setAttribute(Y_WINDOW_ATTRS.exaggeration, String(exaggeration)); + // 고정 배율(B06 공통 Y스케일)에 묶인 그래프만 창을 옮기지 않는다 — 세로 과장은 새 창의 + // px 환산에 그대로 곱하면 되므로 대상이다(B06 종단, 2026-09-04). + if (!yScaleOptions) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1"); const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 // 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은 // 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고). + // 표시 표고창 밖으로 나간 것은 그리지 않는다 — 눈금선·음영·프로파일선이 함께 쓴다. + // (예전에는 이 자리가 아래에 있었다. 눈금선도 세로 변환을 타므로 여기로 올렸다.) + const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`; + const clipPath = svgElement("clipPath", { id: clipId }); + clipPath.append( + svgElement("rect", { + x: LONG_PAD.left, + y: LONG_PAD.top, + width: Math.max(0, widthPx - LONG_PAD.left - LONG_PAD.right), + height: Math.max(0, plotHeight), + }), + ); + const defs = svgElement("defs", {}); + defs.append(clipPath); + // 세로 창이 바뀌면 좌표를 다시 만들지 않고 **이 겹의 변환만** 갈아 끼운다(2026-09-04 + // 사용자 확정 — 「실시간처럼 되려면 표현을 바꿔야 한다」). 자를 영역은 변환 **밖**이라 + // 창이 움직여도 플롯 테두리는 제자리다. 글자는 늘어나므로 이 겹에 넣지 않는다. + const gridLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + const gridWindow = svgElement("g", { class: `${Y_WINDOW_CLASS} ${Y_GRID_CLASS}` }); + gridLayer.append(gridWindow); + const tickLayer = svgElement("g", { class: Y_TICK_CLASS }); + svg.append(defs, gridLayer, tickLayer); + const yAxisTicks: Array<{ y: number; label: string }> = []; const rawSpan = elevationSpan / exaggeration; - const rawTop = elevationMid + rawSpan / 2; + const rawTop = viewCenter + rawSpan / 2; // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). - const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1); - const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9)); - for ( - let value = Math.ceil((elevationMid - rawSpan / 2) / tickStep) * tickStep; - value <= rawTop + 1e-9; - value += tickStep - ) { + for (const { value, label } of yWindowTickValues(viewCenter, rawSpan, plotHeight)) { const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight; - const label = `${value.toFixed(tickDecimals)}m`; yAxisTicks.push({ y: gridY, label }); - svg.append( + gridWindow.append( svgElement("line", { x1: LONG_PAD.left, y1: gridY, @@ -239,23 +340,35 @@ export function createLongitudinalProfile( y2: gridY, class: "b06-chart__grid", }), + ); + tickLayer.append( svgText(label, { x: LONG_PAD.left - 9, y: gridY + 4, "text-anchor": "end", class: "b06-chart__tick", + // 세로 창 변환이 글자를 늘리지 않고 **자리만** 옮기도록 그릴 때의 y를 남긴다. + // 남기는 값은 **눈금선의 y** — 글자를 내리는 4px 은 변환 뒤에 더해야 배율이 + // 커져도 선과 글자 사이가 벌어지지 않는다(2026-09-04 실측 6.4px 어긋남). + [Y_WINDOW_BAKED_ATTR]: String(gridY), }), ); } // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); + const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + // 음영은 세로에 딸린 것 — 변환 겹 안. 균형 구역 경계선은 플롯 높이 전체라 밖에 둔다. + const bandWindow = svgElement("g", { class: Y_WINDOW_CLASS }); + bandLayer.append(bandWindow); + svg.append(bandLayer); + // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. - const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); + const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; appendCutFillBands( - svg, + bandWindow, profile, x, (index) => toY(profile.samples[index].elevation_m), @@ -263,7 +376,7 @@ export function createLongitudinalProfile( ); if (profile.balance_segments.length > 1) { for (const segment of profile.balance_segments.slice(1)) { - svg.append( + bandLayer.append( svgElement("line", { x1: x(segment.start_chainage_m), y1: LONG_PAD.top, @@ -284,6 +397,8 @@ export function createLongitudinalProfile( const selected = station.station_id === selectedStationId; const marker = svgElement("g", { class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, + // B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02). + "data-chainage": station.chainage_m.toFixed(3), tabindex: "0", role: "button", "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, @@ -325,7 +440,8 @@ export function createLongitudinalProfile( // 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시). if (station.structure) { structureLabels.push( - svgText(station.structure, { + // 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙). + svgText(structureDisplayName(station.structure), { x: stationX, y: heightPx - padBottom - 5, "text-anchor": "middle", @@ -338,13 +454,16 @@ export function createLongitudinalProfile( const points = samples .map((sample) => { - const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + const elevated = viewCenter + (sample.elevation_m - viewCenter) * exaggeration; return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; }) .join(" "); + const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + const lineWindow = svgElement("g", { class: Y_WINDOW_CLASS }); + lineLayer.append(lineWindow); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; - svg.append( + lineWindow.append( svgElement("polyline", { points: profile.samples .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) @@ -353,8 +472,9 @@ export function createLongitudinalProfile( }), ); } + lineWindow.append(svgElement("polyline", { points, class: "b06-chart__profile" })); svg.append( - svgElement("polyline", { points, class: "b06-chart__profile" }), + lineLayer, svgElement("line", { x1: LONG_PAD.left, y1: heightPx - padBottom, diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 7568e7b3..a09744a8 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,7 +1,10 @@ +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 { 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,31 +15,44 @@ import { type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { - computeCrossDesign, fetchSectionContext, getSections, - previewCrossDesigns, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; +import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { + bermSpansFromStructures, confirmCurrentSections, + createCutSlopeStore, createRockBoundaryStore, + readBermSpans, saveCurrentSections, + writeBermSpans, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { + readStructurePick, + writeStructurePick, +} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick"; import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View"; -import { staleDesignChainages } from "./B06_Section_UI_Section_Common"; +import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, type PipeOptionsContext, } from "./B06_Section_UI_Page_Pipe_Options"; -import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel"; +import { + createStandardPanel, + effectiveStandardCross, + rememberRockBoundaryDefault, + type StandardPanelController, +} from "./B06_Section_UI_Standard_Panel"; import { createB06StructuresPanel, isWallSelecting, @@ -47,6 +63,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"; @@ -128,8 +145,21 @@ 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); + syncBermSpans(structures); + }, + // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 + // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). + onStructuresChanged: () => void refreshDetailForStructures(), detail: () => sectionDetail, // 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산). wallHeight: (chainageM, role) => { @@ -187,9 +217,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); - // 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다. - const designRequestSeq = new Map(); - /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. @@ -216,33 +243,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를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ @@ -282,18 +295,17 @@ 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; - // 계획고 어긋남은 B05와 **같은 규칙**으로 판정한다(공용 staleDesignChainages). - // 옛 암 2단계 필드 누락은 B06 전용 조건이라 여기서 더한다. - const staleByPlan = new Set(staleDesignChainages(sectionDetail)); - const stale = sectionDetail.cross_sections.filter((section) => { - const design = section.design; - if (!design) return false; - if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; - return staleByPlan.has(section.chainage_m); - }); - if (!stale.length) return; + const draft = readAlignmentDraft(currentRouteId); + // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + + // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. + // + // 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은 + // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 + // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 + // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. + if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { @@ -305,43 +317,62 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }; } | undefined; - const edits = readAlignmentDraft(currentRouteId) ?? { + const edits = draft ?? { station_offsets: alignment?.edits?.station_offsets ?? {}, curve_radii: alignment?.edits?.curve_radii ?? {}, }; - const response = await previewCrossDesigns( + // 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서 + // 다른 값이 된다(2026-09-03 사용자 지시로 일원화). + const updated = await refreshCrossDesigns({ projectId, - currentRouteId, + routeId: currentRouteId, + detail: sectionDetail, edits, - standardPanel?.getValues(), - { fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) }, - ); - const designByChainage = new Map( - response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of sectionDetail.cross_sections) { - const next = designByChainage.get(section.chainage_m.toFixed(3)); - if (next) { - // full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다. - section.design = { - ...(next as NonNullable), - inlet_structure: section.design?.inlet_structure, - basin_adjust: section.design?.basin_adjust, - revet_adjust: section.design?.revet_adjust, - extra_wall_counts: section.design?.extra_wall_counts, - extra_spans: section.design?.extra_spans, - revet_link_detached: section.design?.revet_link_detached, - revet_follow_grade: section.design?.revet_follow_grade, - }; - sectionView.refreshCard(section.chainage_m); - } - } + }); + for (const chainageM of updated) sectionView.refreshCard(chainageM); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Design_Failed")}${detail}`, "error"); } } + /** + * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. + * + * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 + * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. + */ + function syncBermSpans(structures: ReadonlyArray): void { + if (!projectId || currentRouteId === null) return; + const next = bermSpansFromStructures(structures); + if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; + writeBermSpans(projectId, currentRouteId, next); + void reconcileStaleDesigns({ force: true }); + } + + /** 구조물(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, @@ -398,8 +429,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). const rockStore = createRockBoundaryStore({ - sessionKey: () => - projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null, + sessionKey: () => stateKey("rockb", projectId, currentRouteId), detail: () => sectionDetail, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), @@ -407,9 +437,21 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const rockOffsets = rockStore.offsets; const rockBoundaryControl = rockStore.control; + // 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다. + // 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다. + const cutSlopeStore = createCutSlopeStore({ + sessionKey: () => stateKey("cutslope", projectId, currentRouteId), + standard: () => (projectId ? effectiveStandardCross(projectId) : null), + refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + recompute: (chainageM) => recomputeIfRock(chainageM), + }); + const cutSlopeRatios = cutSlopeStore.ratios; + 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, @@ -434,7 +476,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.revetLink, stationControls.ford, stationControls.box, + cutSlopeStore.control, ); + // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). + structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, @@ -449,6 +494,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls, () => sectionDetail, structuresPanel, + // 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼). + (chainageM, key) => writeStructurePick(projectId, chainageM, key), ); // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). // 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다. @@ -458,10 +505,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { : null; if (!target) { structuresPanel.showAtChainage(null); + writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다. return; } const owner = stationControls.structureSpan.ownerOf(target); structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); + // 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. + if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. @@ -505,16 +555,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 { /* 무시 */ } @@ -525,6 +573,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { confirmButton.disabled = sectionDetail === null; } + /** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). + * **넘김값이 바뀌었을 때만** 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 + * 튀고, 진입당 한 번으로 막으면 자료가 늦게 온 경우 영영 안 열린다(2026-09-06). */ + let appliedPick: string | null = null; + function restoreStructurePick(): void { + 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), + fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), + boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role), + }); + } + function renderSectionDetail(): void { if (sectionDetail) { showSectionView(); @@ -539,6 +606,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { `${projectId ?? "-"}:${currentRouteId ?? "-"}`, context?.natural_spoil_min_ground_slope ?? undefined, ); + restoreStructurePick(); } } @@ -552,6 +620,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { flushCulvertOptions: () => stationControls.flushCulvertOptions(), patchSources: () => ({ rockOffsets, + cutSlopeRatios, stationWidths, inletStructures, basinAdjustments, @@ -610,6 +679,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값). // 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시). standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); + // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. + rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { @@ -620,9 +691,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; rockStore.load(); + cutSlopeStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. - void structuresPanel.load(); + void structuresPanel.load().then(restoreStructurePick); try { const existing = await getSections(projectId, context.route_id); if (!existing.longitudinal) { @@ -631,6 +703,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?: { @@ -663,7 +743,7 @@ 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); } 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_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts index cdb5025f..4853d895 100644 --- a/B06_Section/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -16,6 +16,8 @@ import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; export interface CrossPatchSources { /** 암 경계선 오프셋(누가거리 문자열 키). */ rockOffsets: Map; + /** 측점별 암 절토 경사비(1:n 의 n). **0 은 「표준값을 씀」**(되돌리기)이다. */ + cutSlopeRatios: Map; /** 측점 개별 표시 반폭(m). */ stationWidths: Map; inletStructures: Map; @@ -42,6 +44,10 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch sources.rockOffsets.forEach((offset, chainage) => { patchFor(Number(chainage)).rock_boundary_offset_m = offset; }); + // 측점별 암 절토 경사(2026-09-07) — 0 이면 「표준값을 씀」이라 정본의 옛 값을 덮어 지운다. + sources.cutSlopeRatios.forEach((ratio, chainage) => { + patchFor(Number(chainage)).cut_slope_ratio_user = ratio; + }); // 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다. sources.stationWidths.forEach((width, chainage) => { patchFor(Number(chainage)).display_half_width_m = width; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 765edcfa..e45c50c3 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -16,15 +16,140 @@ 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 { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; +import { + applyStructureAreaRows, + STRUCTURE_ROW_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 type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; +/** + * 세션에 쌓인 암 경계선 오프셋(측점키 → 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 readRockBoundarySession( + projectId: string, + routeId: number, +): Record { + 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); +} + +/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ +export const BERM_TYPE_ID = "berm"; + +/** + * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 + * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). + * + * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 + * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 + * 그 사본만 본다. + */ +export function bermSpansFromStructures( + structures: ReadonlyArray<{ + type_id: string; + start_m?: number | null; + end_m?: number | null; + chainage_m?: number | null; + options?: Record; + }>, +): BermSpan[] { + const spans: BermSpan[] = []; + for (const item of structures) { + if (item.type_id !== BERM_TYPE_ID) continue; + const anchor = item.chainage_m ?? item.start_m ?? null; + const start = item.start_m ?? anchor; + const end = item.end_m ?? anchor; + if (start === null || end === null) continue; + const options = item.options ?? {}; + const number = (key: string, fallback: number): number => { + const parsed = Number(options[key]); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const width = number("width_m", BERM_DEFAULT_WIDTH_M); + const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 + spans.push({ + start_m: Math.min(start, end), + end_m: Math.max(start, end), + width_m: width, + interval_m: interval, + slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + }); + } + return spans; +} + +/** 그 측점을 덮는 소단 제원 — 없으면 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)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ @@ -65,7 +190,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 { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -78,7 +203,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]) => { @@ -125,6 +250,96 @@ export function createRockBoundaryStore(options: { }; } +/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ +export interface CutSlopeStore { + /** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다. + * **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이 + * 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */ + ratios: Map; + control: CutSlopeControl; + load: () => void; +} + +/** + * 측점 하나만 다른 **암 절토 경사** 세션 저장소. + * + * 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서 + * `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다. + * + * ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고 + * 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다. + */ +export function createCutSlopeStore(options: { + sessionKey: () => string | null; + /** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */ + standard: () => StandardCrossSection | null; + refreshCard: (chainageM: number) => void; + /** 경사 변경 → 사면·소단·단면적 재계산. */ + recompute: (chainageM: number) => void; +}): CutSlopeStore { + const { sessionKey, standard, refreshCard, recompute } = options; + const ratios = new Map(); + const key = (chainageM: number): string => chainageM.toFixed(2); + + /** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */ + const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => { + const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock"; + const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined; + const value = group?.cut_slope_ratio; + return typeof value === "number" && value > 0 ? value : 0.4; + }; + + function persist(): void { + const storageKey = sessionKey(); + if (!storageKey) return; + try { + writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + return { + ratios, + load(): void { + ratios.clear(); + const storageKey = sessionKey(); + if (!storageKey) return; + try { + const raw = readByKey(storageKey); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, ratio]) => { + // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. + if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); + }); + } catch { + /* 손상된 세션 값은 무시 — 표준값으로 재시작. */ + } + }, + control: { + standardRatioFor: (section) => standardRatio(section), + ratioFor: (section) => { + const session = ratios.get(key(section.chainage_m)); + if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다. + if (typeof session === "number" && session > 0) return session; + const stored = (section.design as { cut_slope_ratio_user?: number } | undefined) + ?.cut_slope_ratio_user; + if (typeof stored === "number" && stored > 0) return stored; + return standardRatio(section); + }, + set: (chainageM, ratio) => { + // null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다. + if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0); + else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000); + persist(); + refreshCard(chainageM); + recompute(chainageM); + }, + }, + }; +} + /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ export interface SectionPersistContext { projectId: string | null; @@ -144,26 +359,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_ROW_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, }; } @@ -172,9 +415,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"); @@ -196,6 +450,10 @@ export async function saveCurrentSections(ctx: SectionPersistContext): Promise void; + refreshCard: (chainageM: number) => void; + revetSelect: (chainageM: number, key: RevetKey) => void; + fordSelect: (chainageM: number, role: "inlet" | "outlet") => void; + boxSelect: (chainageM: number, role: "left" | "right") => void; +} + +/** + * 넘김값을 화면에 적용한다. 값이 없거나 그 측점이 이 노선에 없으면 아무것도 안 한다. + * 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다. + */ +export function applyStructurePick( + handoff: StructurePickHandoff | null, + detail: SectionDetailResponse | null, + targets: PickTargets, +): void { + if (!handoff) return; + // 허용오차는 같은 화면의 다른 대조와 같은 0.05m 다 — 0.01m 로는 3D 가 준 누가거리가 + // 소수점 아래에서 조금만 달라도 그 측점을 못 찾고 조용히 끝났다(2026-09-05 진단). + const target = detail?.cross_sections.find( + (section) => Math.abs(section.chainage_m - handoff.at) < 0.05, + ); + if (!target) return; + // 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다. + const key = handoff.key?.split(":")[0]; + requestAnimationFrame(() => { + targets.focusStation(target.station_id); + if (!key || key === "body") return; + if (target.ford) { + if (key !== "inlet" && key !== "outlet") return; + targets.fordSelect(target.chainage_m, key); + } else if (target.box) { + if (key !== "left" && key !== "right") return; + targets.boxSelect(target.chainage_m, key); + } else { + if (!REVET_KEYS.test(key)) return; + targets.revetSelect(target.chainage_m, key as RevetKey); + } + targets.refreshCard(target.chainage_m); + }); +} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index fabc9eb7..b3bb3fd4 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -18,11 +18,17 @@ 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 { + readStructurePick, + writeStructurePick, +} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { showToast } from "@ui/ui_template_elements"; import { adjustDockRoot, @@ -56,6 +62,10 @@ export interface B06StructuresPanelDeps { stationInterval: () => number; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ 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; @@ -65,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 { @@ -217,61 +230,74 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, ); - const section = createStructuresSection({ - onChange: (next) => { - structures = withLocalIds(next); - section.setStructures(structures); - if (deps.projectId) writePendingStructures(deps.projectId, structures); - }, - onSelect: (structure) => { - if (structure) deps.focusChainage(structureAnchorM(structure)); - }, - getInterval: deps.stationInterval, - onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), - // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → - // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. - onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 - // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 - // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; - if (moved) { - if (deps.movePipe) { - deps.movePipe(fromChainageM, toChainageM); - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.chainage_m = toChainageM; - currentChainageM = toChainageM; - section.setPipeFacilities(pipeFacilities); - showToast(PIPE_MOVE_NOTICE, "success"); - } else { - showToast(PIPE_MOVE_GUIDE, "error"); + 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)); + }, + getInterval: deps.stationInterval, + onReveal: () => deps.reveal?.(), + onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), + // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → + // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. + onPipeUpdate: (fromChainageM, toChainageM, attributes) => { + // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 + // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 + // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). + const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; + if (moved) { + if (deps.movePipe) { + deps.movePipe(fromChainageM, toChainageM); + const hit = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, + ); + if (hit) hit.chainage_m = toChainageM; + currentChainageM = toChainageM; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + showToast(PIPE_MOVE_NOTICE, "success"); + } else { + showToast(PIPE_MOVE_GUIDE, "error"); + } } - } - const patch = attributes.options; - if (!patch || !Object.keys(patch).length) return; - if (!deps.queuePipeOptions) { - showToast(PIPE_ADD_GUIDE, "error"); - return; - } - // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 - // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). - // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. - deps.queuePipeOptions(fromChainageM, patch as Record); - deps.applyPipeOptions?.(fromChainageM, patch as Record); - // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; - section.setPipeFacilities(pipeFacilities); + const patch = attributes.options; + if (!patch || !Object.keys(patch).length) return; + if (!deps.queuePipeOptions) { + showToast(PIPE_ADD_GUIDE, "error"); + return; + } + // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 + // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). + // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. + deps.queuePipeOptions(fromChainageM, patch as Record); + deps.applyPipeOptions?.(fromChainageM, patch as Record); + // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. + const hit = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, + ); + if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; + section.setPipeFacilities(pipeFacilities); + }, + onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), + onPipeSelect: (chainageM) => { + // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. + writeStructurePick(deps.projectId, chainageM); + if (chainageM !== null) deps.focusChainage(chainageM); + }, }, - onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), - onPipeSelect: (chainageM) => { - if (chainageM !== null) deps.focusChainage(chainageM); - }, - }); + { includeDetail: true }, + ); // 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이 // 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: @@ -300,11 +326,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); @@ -332,6 +362,13 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc )?.design_flow_m3s ?? null, })); section.setPipeFacilities(pipeFacilities); + // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. + pushMarks(); + // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 + // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 + // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. + const picked = currentChainageM ?? readStructurePick(deps.projectId)?.at ?? null; + if (picked !== null) section.selectPipeByChainage(picked); } catch (error) { showToast( error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", @@ -435,6 +472,9 @@ export function wireStructureSelection( stationControls: StationControls, detail: () => SectionDetailResponse | null, panel: B06StructuresPanel, + /** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가 + * 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */ + remember?: (chainageM: number, key: string) => void, ): { syncInletStructure: () => void } { const ownerChainageOf = (chainageM: number): number => { const sectionAt = detail()?.cross_sections.find( @@ -463,6 +503,7 @@ export function wireStructureSelection( stationControls.revetOffset.select = (chainageM, key) => { markWallSelecting(); origRevet(chainageM, key); + if (key) remember?.(ownerChainageOf(chainageM), key); panel.showPipeAt(key ? ownerChainageOf(chainageM) : null); if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. @@ -473,6 +514,7 @@ export function wireStructureSelection( stationControls.ford.select = (chainageM, role) => { markWallSelecting(); origFord(chainageM, role); + if (role) remember?.(chainageM, role); panel.showPipeAt(role ? chainageM : null); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). @@ -482,6 +524,7 @@ export function wireStructureSelection( stationControls.box.select = (chainageM, role) => { markWallSelecting(); origBox(chainageM, role); + if (role) remember?.(chainageM, role); panel.showPipeAt(role ? chainageM : null); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index a6416965..f745e7b6 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -7,6 +7,8 @@ * 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다. * ========================================================================== */ +import { PIPE_TYPES } from "@config/config_frontend"; +import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import type { DesignProfile, @@ -56,6 +58,17 @@ export interface YScaleOptions { globalMaxElevation: number; } +/** + * 구조물 라벨의 **표시 이름** — 배수관은 관종·관경(`파형강관 D1200`) 대신 `배수관` 하나로 + * 적는다(2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙, 2026-09-03 종·횡단 표기에 적용). + * + * 저장 라벨은 그대로 두어야 한다 — 재진입 이관(`B05_Profile_Structures_Migration`)이 라벨 + * 문자열로 구조물 종류를 되짚는다. 그래서 바꾸는 것은 화면에 찍는 순간뿐이다. + */ +export function structureDisplayName(label: string): string { + return PIPE_TYPES.some((kind) => label.startsWith(`${kind} D`)) ? PIPE_DISPLAY_NAME : label; +} + export function validElevation( sample: SectionSample, ): sample is SectionSample & { elevation_m: number } { @@ -65,11 +78,7 @@ export function validElevation( } /** - * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. - * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. - */ -/** - * 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**을 찾는다(B05·B06 공용 규칙). + * 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**이 하나라도 있는가(B05·B06 공용 규칙). * * 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를 * 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙 @@ -77,47 +86,102 @@ export function validElevation( * — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측: * 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은 * 시점에 같은 조치를 하게 한다. + * + * 그리기마다 불리므로 **첫 건에서 멈춘다** — 전 측점을 훑어 목록을 만들던 옛 판은 + * 프레임당 2.33ms 를 먹어 계획선 편집을 굼뜨게 했다(2026-09-03 실측). */ -export function staleDesignChainages( +export function hasStaleDesigns( detail: { longitudinal: { design_profiles?: DesignProfile[] }; - cross_sections: Array<{ chainage_m: number; design?: { design_elevation_m: number } | null }>; + cross_sections: StaleCandidate[]; }, toleranceM = 1e-3, -): number[] { +): boolean { const profiles = detail.longitudinal.design_profiles; - if (!profiles?.length) return []; - return detail.cross_sections - .filter((section) => { - const design = section.design; - if (!design) return false; - const planned = designElevationAt(profiles, section.chainage_m); - return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM; - }) - .map((section) => section.chainage_m); + return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM)); } +interface StaleCandidate { + chainage_m: number; + design?: { + design_elevation_m: number; + geometry_preset?: string; + two_stage_slope?: boolean; + surface_drop_m?: number; + } | null; +} + +function isStaleSection( + section: StaleCandidate, + profiles: DesignProfile[] | undefined, + toleranceM: number, +): boolean { + const design = section.design; + if (!design) return false; + // 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다. + // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 + // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. + if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + if (!profiles?.length) return false; + const planned = designElevationAt(profiles, section.chainage_m); + if (planned === undefined) return false; + // 세월교가 앉은 측점의 노면은 월류 높이만큼 **일부러** 내려 앉는다 — 그 차이를 빼지 + // 않으면 계획선을 안 건드려도 늘 어긋난 것으로 나온다(2026-09-03 사용자 보고). + const designed = design.design_elevation_m + (design.surface_drop_m ?? 0); + return Math.abs(planned - designed) > toleranceM; +} + +/** + * 계획선 샘플에서 유효분만 걸러 둔 사본 — **샘플 배열 하나당 한 번만** 만든다. + * + * 이 함수는 측점마다 불린다(측점 128 × 샘플 2,159 규모). 호출마다 `filter()` 로 새 + * 배열을 만들면 그리기 한 프레임이 통째로 그 비용에 먹힌다(2026-09-03 실측: 프레임당 + * 2.33ms, 계획선 편집이 굼떠지는 원인). 원본 배열이 바뀌면 새 객체가 오므로 키로 쓴다. + */ +const validSamplesCache = new WeakMap>(); + +function validPlanSamples( + designProfiles: DesignProfile[] | undefined, +): Array<{ chainage_m: number; elevation_m: number }> | undefined { + const samples = designProfiles?.[0]?.samples; + if (!samples?.length) return undefined; + const cached = validSamplesCache.get(samples); + if (cached) return cached.length ? cached : undefined; + const filtered = samples.filter((sample) => Number.isFinite(sample.elevation_m)) as Array<{ + chainage_m: number; + elevation_m: number; + }>; + validSamplesCache.set(samples, filtered); + return filtered.length ? filtered : undefined; +} + +/** + * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. + * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. + */ export function designElevationAt( designProfiles: DesignProfile[] | undefined, chainageM: number, ): number | undefined { - const samples = designProfiles?.[0]?.samples?.filter((sample) => - Number.isFinite(sample.elevation_m), - ); - if (!samples?.length) return undefined; + const samples = validPlanSamples(designProfiles); + if (!samples) return undefined; if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m; const last = samples[samples.length - 1]; if (chainageM >= last.chainage_m) return last.elevation_m; - for (let index = 1; index < samples.length; index += 1) { - const previous = samples[index - 1]; - const current = samples[index]; - if (chainageM > current.chainage_m) continue; - const span = current.chainage_m - previous.chainage_m; - if (span <= 0) return current.elevation_m; - const ratio = (chainageM - previous.chainage_m) / span; - return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + // 누가거리 순 배열이라 이진탐색으로 구간을 고른다(선형 훑기와 결과는 같다). + let low = 1; + let high = samples.length - 1; + while (low < high) { + const mid = (low + high) >> 1; + if (samples[mid].chainage_m < chainageM) low = mid + 1; + else high = mid; } - return last.elevation_m; + const previous = samples[low - 1]; + const current = samples[low]; + const span = current.chainage_m - previous.chainage_m; + if (span <= 0) return current.elevation_m; + const ratio = (chainageM - previous.chainage_m) / span; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; } /** @@ -159,6 +223,47 @@ export function calculateYScale( }; } +/** + * **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정). + * + * 가로로 확대하면 화면에는 노선의 일부만 남는데 Y 축은 전 구간 범위로 잡혀 있어 곡선이 + * 납작하게 눌린다. 보이는 누가거리 구간만 훑어 그 구간의 범위를 돌려준다 — B05 종단과 + * B06 종단이 같은 함수를 쓴다. + * + * 창 밖 **이웃 한 점**까지 함께 본다. 창 경계를 걸친 선분이 창 안에서 위로 솟는데 그 + * 바깥 끝점을 빼면 선이 축 위로 삐져나온다. + */ +export function windowElevationRange( + series: ReadonlyArray>, + fromM: number, + toM: number, +): { min: number; max: number } | undefined { + let min = Infinity; + let max = -Infinity; + for (const list of series) { + let first = -1; + let last = -1; + for (let index = 0; index < list.length; index += 1) { + const chainage = list[index].chainage_m ?? 0; + if (chainage < fromM || chainage > toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(list.length - 1, last + 1); + index += 1 + ) { + const elevation = list[index].elevation_m; + if (typeof elevation !== "number" || !Number.isFinite(elevation)) continue; + if (elevation < min) min = elevation; + if (elevation > max) max = elevation; + } + } + return min <= max ? { min, max } : undefined; +} + export function emptyView(message: string): HTMLElement { const empty = document.createElement("div"); empty.className = "b06-section__empty"; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 515cee82..0d5111e9 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -13,6 +13,8 @@ * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. * ========================================================================== */ +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; +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,45 +36,52 @@ 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 { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; +import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; import { - applyLegendToggle, - computeMassHaulSeries, - MASS_HAUL_BALANCE_KEY, - type MassHaulSeries, -} from "@util/common_util_mass_haul"; -import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; + buildLongitudinalChart, + visibleChainageRange, + visibleElevationRange, +} from "./B06_Section_UI_Section_View_Chart"; +import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; +import { computeMassHaulSeries } from "@util/common_util_mass_haul"; +import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; import { - configureBalloonOffsets, - resetBalloonOffsets, -} from "@util/common_util_mass_haul_balance_view"; -import { - createMassHaulChart, - createMassHaulLegend, - createMassHaulSummary, - MASS_HAUL_MIN_HEIGHT, -} from "@util/common_util_mass_haul_view"; + applyElevationWindow, + needsFullRedraw, + Y_AXIS_WINDOW_CLASS, + type ElevationWindowResult, +} from "@util/common_util_chart_ywindow"; 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, - unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { - calculateYScale, CROSS_GRID_GAP, CROSS_GRID_MIN_WIDTH, CROSS_WIDTH, @@ -82,9 +91,6 @@ import { emptyView, inferStationInterval, L, - longitudinalMaxChainage, - LONG_PAD, - type YScaleOptions, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; @@ -119,6 +125,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; } @@ -135,6 +147,8 @@ export function createSectionView( revetLink?: RevetLinkControl, ford?: FordControl, box?: BoxControl, + /** 측점별 암 절토 경사 입력(2026-09-07). */ + cutSlope?: CutSlopeControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -146,18 +160,16 @@ 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; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) - let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). @@ -194,11 +206,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 하단 패널과 같은 처리). @@ -419,7 +431,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, @@ -443,6 +459,8 @@ export function createSectionView( revetLink, ford, box, + plotBase, + cutSlope, ); /** @@ -460,15 +478,12 @@ 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(); - }; + /** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */ + /** 최종 누가토량 배지 — 종단 그래프 좌측 상단(2026-09-06 사용자 지시). */ + const massBadge = createMassHaulBadge(); + panelBody.append(massBadge.root); + /** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */ + let updateChartWindow: (() => ElevationWindowResult | null) | null = null; /** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */ function drawPanel(): void { @@ -482,114 +497,100 @@ 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); - // 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다. - cachedYScale = calculateYScale(detail, heights.long); - - // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 - // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). - let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - const nodes: Element[] = [ - unwrapChart( - createLongitudinalProfile( - detail.longitudinal, - selectedStationId, - currentExaggeration, - cachedYScale, - (stationId) => selectStation(stationId, true), - cachedStationInterval, - chartWidth, - heights.long, - minWidth, - detail.longitudinal.design_profiles ?? [], - 0, - (axis) => { - longAxis = axis; - }, - ), - ), - ]; - - const series: MassHaulSeries[] = currentConversion - ? computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - currentConversion, - currentNaturalSpoilSlope, - ) - : []; - // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 - // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. - const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); - const haulPlan = - bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) - ? computeHaulPlan(bandedSeries.result, currentHaulLimits) - : null; - if (series.length) { - // 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면 - // 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다. + const chart = buildLongitudinalChart({ + detail, + selectedStationId, + exaggeration: currentExaggeration, + selectStation: (stationId) => selectStation(stationId, true), + stationInterval: cachedStationInterval, + chartWidth, + chartHeight: heights.long, + minWidth, + scrollLeft: keepScrollLeft, + viewportWidth: chartWrap.clientWidth || chartWidth, + }); + const longAxis = chart.axis; + const nodes: Element[] = [chart.node]; + // 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다. + // 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). + if (markStructures.length && markTypes.length) { nodes.push( - createMassHaulChart( - series, - visibleSeries, - detail.longitudinal, - { - maxChainageM: longitudinalMaxChainage(detail.longitudinal), - padLeft: LONG_PAD.left, - padRight: LONG_PAD.right, + 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); }, - selectedStationId, - cachedStationInterval, - chartWidth, - heights.mass, - minWidth, - (stationId) => selectStation(stationId, true), - haulPlan, - (axis) => { - massAxis = axis; - }, - ), + // 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다. + onMove: () => undefined, + }), ); } + chartWrap.replaceChildren(...nodes); - // 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 - // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼 - // 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다. - if (massAxis) { - const overlay = buildStickyYAxis(massAxis, heights.mass); - (overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`; + // 유토곡선 그래프는 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) { + const overlay = buildStickyYAxis(longAxis, heights.long); + // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). + overlay.classList.add(Y_AXIS_WINDOW_CLASS); chartWrap.prepend(overlay); } - if (longAxis) chartWrap.prepend(buildStickyYAxis(longAxis, heights.long)); - panel.querySelector(".b06-masshaul__legend")?.remove(); - panel.querySelector(".b06-masshaul__summary")?.remove(); - // 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 다만 **스크롤 컨테이너 - // 밖**에서 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 끼어들어 두 그래프의 - // 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). 세로 위치는 종단면도 높이로 잡는다. - if (series.length) { - const legend = createMassHaulLegend(series, visibleSeries, toggleSeries, () => { - resetBalloonOffsets(); - drawPanel(); - }); - legend.style.top = `${heights.long + 6}px`; - panelBody.append(legend); - } - // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. - // (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.) - const summarySeries = bandedSeries; - if (summarySeries) { - panelCount.textContent = ""; - panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); - } else { - panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); - } + // 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다. + updateChartWindow = () => { + const { fromM, toM } = visibleChainageRange( + chart.toChainage, + chart.maxChainageM, + chartWrap.scrollLeft, + chartWrap.clientWidth || chartWidth, + ); + return applyElevationWindow( + chartWrap, + visibleElevationRange(detail, fromM, toM) ?? undefined, + ); + }; chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); @@ -607,6 +608,23 @@ export function createSectionView( } } + /** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ + const SCROLL_SETTLE_MS = 80; + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) — + // 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다. + chartWrap.addEventListener("scroll", () => { + const fitted = updateChartWindow?.() ?? null; + window.clearTimeout(scrollSettleTimer); + if (!needsFullRedraw(fitted)) return; + scrollSettleTimer = window.setTimeout(() => { + if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = chartWrap.scrollLeft; + drawPanel(); + }, SCROLL_SETTLE_MS); + }); + const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; const detail = currentDetail; @@ -634,16 +652,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)); @@ -653,7 +677,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 { @@ -699,7 +723,9 @@ export function createSectionView( crossHalfWidth, stationInterval, earthworkConversion, - haulEquipmentLimits, + // 운반 장비 한계는 곡선(운반 블록)에만 쓰던 값이라 B06 에서는 더 받지 않는다 + // (2026-09-06 유토곡선 그래프 제거). 인자 자리는 호출부 호환을 위해 남긴다. + _haulEquipmentLimits, balloonScope, naturalSpoilMinSlope, ) { @@ -710,7 +736,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); @@ -728,6 +753,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 new file mode 100644 index 00000000..7ab41541 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_Chart.ts + * B06 상단 패널의 **종단면도 몫**만 떼어 낸 조립기 (2026-09-04 · 700줄 제한). + * + * 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, B05 와 같은 규칙·같은 + * 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. 스크롤할 때 그래프를 다시 + * 만들지 않고 세로 창을 **변환으로** 옮기는 것도 B05 와 같다 + * (`common_util_chart_ywindow`). + * ========================================================================== */ + +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { createLongitudinalProfile } from "./B06_Section_UI_Longitudinal"; +import { + LONG_PAD, + longitudinalMaxChainage, + windowElevationRange, +} from "./B06_Section_UI_Section_Common"; +import { unwrapChart } from "./B06_Section_UI_Section_View_Panel"; + +export interface LongitudinalChartInput { + detail: SectionDetailResponse; + selectedStationId: string | null; + /** 세로 과장(사용자 조절값). */ + exaggeration: number; + selectStation: (stationId: string) => void; + stationInterval: number; + chartWidth: number; + chartHeight: number; + minWidth: number; + /** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */ + scrollLeft: number; + viewportWidth: number; +} + +export interface LongitudinalChartResult { + node: Element; + 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; +} + +/** 지금 보이는 구간(누가거리 m)을 잰다 — 그릴 때와 스크롤 갱신 때가 **같은 식**을 쓴다. */ +export function visibleChainageRange( + toChainage: (px: number) => number, + maxChainageM: number, + scrollLeft: number, + viewportWidth: number, +): { fromM: number; toM: number } { + return { + fromM: Math.max(0, toChainage(scrollLeft)), + toM: Math.min(maxChainageM, toChainage(scrollLeft + viewportWidth)), + }; +} + +/** 보이는 구간의 표고 범위 — 종단 세로 창의 기준. */ +export function visibleElevationRange( + detail: SectionDetailResponse, + fromM: number, + toM: number, +): { min: number; max: number } | null { + return ( + windowElevationRange( + [ + detail.longitudinal.samples, + ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), + ], + fromM, + toM, + ) ?? null + ); +} + +export function buildLongitudinalChart(input: LongitudinalChartInput): LongitudinalChartResult { + const { detail } = input; + 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, + input.scrollLeft, + input.viewportWidth, + ); + + // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 + // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). + let axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; + const node = unwrapChart( + createLongitudinalProfile( + detail.longitudinal, + input.selectedStationId, + input.exaggeration, + // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. + undefined, + input.selectStation, + input.stationInterval, + input.chartWidth, + input.chartHeight, + input.minWidth, + detail.longitudinal.design_profiles ?? [], + 0, + (next) => { + axis = next; + }, + undefined, + undefined, + 0, + 0, + visibleElevationRange(detail, fromM, toM) ?? undefined, + ), + ); + return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM }; +} 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 1f22ce0c..fed5075f 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -1,6 +1,12 @@ /* ============================================================================= * B06_Section_UI_Standard_Panel.ts - * 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹). + * 좌측 사이드 "표준 횡단면 설정" 패널 — **「표준횡단면 상세값」 한 컨테이너**(2026-09-04 + * 사용자 지시). 토사·암·포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌 + + * 구간별로 다른 값만 남겼다. 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, + * 암 = 절토 경사·L형 측구, 포장 = 횡단 경사. + * + * **저장 구조는 그대로 3그룹**(`standard_cross_section`) — 화면만 합치고 저장할 때 + * 공통값을 세 그룹에 펼쳐 넣는다. 백엔드·기존 프로젝트가 그대로 동작한다. * * 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config * (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가 @@ -11,9 +17,17 @@ * 카드에서 이뤄진다(여기서는 값만 보관). * ========================================================================== */ +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 { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; +import { ratioToDegrees } from "./B06_Section_UI_Cross_CutSlope"; import { getCompanyStandard, listCompanyStandards, @@ -26,25 +40,49 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [ - ["soil", "B06_Std_Group_Soil"], - ["rock", "B06_Std_Group_Rock"], - ["paved", "B06_Std_Group_Paved"], -]; +/** + * 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답). + * + * 횡단 계산이 브라우저에서 돌므로 config 수치가 프론트에도 있어야 하는데, 상수를 + * 복제하면 정본이 둘이 된다. 그래서 복제 대신 **서버가 준 값을 그대로 기억**한다. + * 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환). + */ +export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void { + writeState("std-cross-default", defaults, projectId); +} -const SESSION_PREFIX = "b06:std-cross:"; +/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */ +export function readStandardDefaults(projectId: string): StandardCrossSection | null { + return readState("std-cross-default", projectId); +} -function sessionKey(projectId: string): string { - return `${SESSION_PREFIX}${projectId}`; +/** + * 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다. + * 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면 + * 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다. + */ +export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void { + writeStateRaw("rock-boundary-default", String(offsetM), projectId); +} + +/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */ +export function readRockBoundaryDefault(projectId: string): number | null { + const raw = readStateRaw("rock-boundary-default", projectId); + const parsed = raw === null ? Number.NaN : Number(raw); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**. + * 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다. + */ +export function effectiveStandardCross(projectId: string): StandardCrossSection | null { + return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId); } /** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */ export function clearStandardCrossSession(projectId: string): void { - try { - window.sessionStorage.removeItem(sessionKey(projectId)); - } catch { - /* 세션 접근이 막혀도 초기화는 계속한다. */ - } + clearState("std-cross", projectId); } /** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */ @@ -52,22 +90,18 @@ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection; } -/** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */ -function readSession(projectId: string): StandardCrossSection | null { - try { - const raw = window.sessionStorage.getItem(sessionKey(projectId)); - return raw ? (JSON.parse(raw) as StandardCrossSection) : null; - } catch { - return null; - } +/** + * 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. + * + * 패널 밖에서도 필요하다 — 횡단 재계산 단일 창구(`B06_Section_Cross_Refresh`)가 B05처럼 + * 패널이 없는 화면에서도 **같은 표준 단면값**을 서버로 보내야 두 화면 결과가 같다. + */ +export function readStandardCrossSession(projectId: string): StandardCrossSection | 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 { @@ -81,49 +115,98 @@ export interface StandardPanelController { applyStored: (stored: StandardCrossSection) => void; } +/** 값을 어느 그룹에 쓸 것인가. `common` 은 세 그룹에 함께 펼쳐 넣는다. */ +type FieldScope = "common" | "rock" | "paved"; + interface NumberFieldSpec { - label: string; + label: keyof typeof ui_locales; + scope: FieldScope; + /** 화면에 보일 값을 읽는다 — 공통은 **토사 값이 기준**(2026-09-04 사용자 확정). */ get: (group: StandardCrossGroup) => number; set: (group: StandardCrossGroup, value: number) => void; - /** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */ - only?: StandardCrossKey; + /** + * 공통값이지만 이 그룹은 따로 값을 갖는다 — 공통을 펼칠 때 건너뛴다. + * (절토 경사는 암이, 횡단 경사는 포장이 자기 값을 쓴다) + */ + exclude?: StandardCrossKey; } -/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */ +/** 한 컨테이너에 늘어놓을 편집 필드. 순서 = 화면 표기 순서. */ const FIELD_SPECS: NumberFieldSpec[] = [ { label: "B06_Std_Field_RoadWidth", + scope: "common", get: (g) => g.road_width_m, set: (g, v) => (g.road_width_m = v), }, { label: "B06_Std_Field_ShoulderLeft", + scope: "common", get: (g) => g.shoulder_left_m, set: (g, v) => (g.shoulder_left_m = v), }, { label: "B06_Std_Field_ShoulderRight", + scope: "common", get: (g) => g.shoulder_right_m, set: (g, v) => (g.shoulder_right_m = v), }, { label: "B06_Std_Field_DitchTop", + scope: "common", get: (g) => g.ditch.top_width_m, set: (g, v) => (g.ditch.top_width_m = v), }, { label: "B06_Std_Field_DitchBottom", + scope: "common", get: (g) => g.ditch.bottom_width_m, set: (g, v) => (g.ditch.bottom_width_m = v), }, { label: "B06_Std_Field_DitchDepth", + scope: "common", get: (g) => g.ditch.depth_m, set: (g, v) => (g.ditch.depth_m = v), }, + { + // 암은 아래에서 자기 절토 경사를 따로 가진다 — 공통은 토사·포장 몫이다. + label: "B06_Std_Field_CutSlope", + scope: "common", + exclude: "rock", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, + { + label: "B06_Std_Field_FillSlope", + scope: "common", + get: (g) => g.fill_slope_ratio, + set: (g, v) => (g.fill_slope_ratio = v), + }, + { + // 포장은 아래에서 자기 횡단 경사를 따로 가진다. + label: "B06_Std_Field_CrossSlopeMin", + scope: "common", + exclude: "paved", + get: (g) => g.cross_slope_pct.min, + set: (g, v) => (g.cross_slope_pct.min = v), + }, + { + label: "B06_Std_Field_CrossSlopeMax", + scope: "common", + exclude: "paved", + get: (g) => g.cross_slope_pct.max, + set: (g, v) => (g.cross_slope_pct.max = v), + }, + { + label: "B06_Std_Field_CutSlope", + scope: "rock", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, { label: "B06_Std_Field_LDitchWidth", - only: "rock", + scope: "rock", get: (g) => g.ditch_l_type?.width_m ?? 0, set: (g, v) => { g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 }; @@ -131,34 +214,49 @@ const FIELD_SPECS: NumberFieldSpec[] = [ }, { label: "B06_Std_Field_LDitchDepth", - only: "rock", + scope: "rock", get: (g) => g.ditch_l_type?.depth_m ?? 0, set: (g, v) => { g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v }; }, }, - { - label: "B06_Std_Field_CutSlope", - get: (g) => g.cut_slope_ratio, - set: (g, v) => (g.cut_slope_ratio = v), - }, - { - label: "B06_Std_Field_FillSlope", - get: (g) => g.fill_slope_ratio, - set: (g, v) => (g.fill_slope_ratio = v), - }, { label: "B06_Std_Field_CrossSlopeMin", + scope: "paved", get: (g) => g.cross_slope_pct.min, set: (g, v) => (g.cross_slope_pct.min = v), }, { label: "B06_Std_Field_CrossSlopeMax", + scope: "paved", get: (g) => g.cross_slope_pct.max, set: (g, v) => (g.cross_slope_pct.max = v), }, ]; +/** 공통 필드 하나를 그룹들에 펼쳐 넣는다(자기 값을 갖는 그룹은 건너뛴다). */ +function spread(state: StandardCrossSection, spec: NumberFieldSpec, value: number): void { + for (const key of ["soil", "rock", "paved"] as StandardCrossKey[]) { + if (spec.exclude === key) continue; + const group = state[key]; + if (group) spec.set(group, value); + } +} + +/** + * 그룹마다 공통값이 다르게 저장돼 있을 수 있다(옛 프로젝트) — **토사 값을 기준**으로 + * 한 벌로 맞춘다(2026-09-04 사용자 확정). 화면은 값 하나를 보이는데 저장분이 셋으로 + * 갈려 있으면 어느 값이 나갔는지 알 수 없기 때문이다. + */ +function unifyCommon(state: StandardCrossSection): void { + const soil = state.soil; + if (!soil) return; + for (const spec of FIELD_SPECS) { + if (spec.scope !== "common") continue; + spread(state, spec, spec.get(soil)); + } +} + /** * 표준 횡단면 설정 패널을 만든다. * @param projectId 세션 캐시 스코프. @@ -169,8 +267,10 @@ export function createStandardPanel( defaults: StandardCrossSection, onApplyAll?: () => void | Promise, ): StandardPanelController { + // 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다. + rememberStandardDefaults(projectId, defaults); // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. - const sessionValue = readSession(projectId); + const sessionValue = readStandardCrossSession(projectId); const hadSession = sessionValue !== null; const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults); @@ -186,49 +286,86 @@ export function createStandardPanel( const persist = (): void => writeSession(projectId, state); - const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => { - const group = state[key]; - // B05 "기준값 직접 지정"과 동일한 details/summary 패턴, 기본 접힘(N-4-1). - const fieldset = document.createElement("details"); - fieldset.className = "b06-std__group"; - const legend = document.createElement("summary"); - legend.className = "b06-std__legend"; - legend.textContent = L(legendKey); - fieldset.append(legend); + /** 필드 한 칸. 공통은 토사 값을 보이고, 고치면 세 그룹에 함께 펼친다. */ + const buildField = (spec: NumberFieldSpec, grid: HTMLElement): void => { + const source = spec.scope === "common" ? state.soil : state[spec.scope]; + if (!source) return; + const field = createInputField({ + label: L(spec.label), + type: "number", + value: String(spec.get(source)), + onInput: (raw) => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + if (spec.scope === "common") spread(state, spec, parsed); + else spec.set(source, parsed); + persist(); + }, + }); + field.input.step = "0.1"; + field.input.min = "0"; + // 경사 칸은 **각도도 함께 보인다** — 카드의 개별 절토각 칸이 도(°)로 받으므로 두 자리의 + // 말이 갈리지 않게 한다(2026-09-07). 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + if (spec.label === "B06_Std_Field_CutSlope" || spec.label === "B06_Std_Field_FillSlope") { + const showAngle = (): void => { + const ratio = Number(field.input.value); + field.input.title = + Number.isFinite(ratio) && ratio > 0 + ? `1:${ratio} = ${ratioToDegrees(ratio).toFixed(1)}° (횡단 카드의 각도 칸과 같은 값)` + : ""; + }; + showAngle(); + field.input.addEventListener("input", showAngle); + } + grid.append(field.root); + }; + /** 구분선 + 구간 이름 — 아래 값들이 그 구간에서만 쓰인다는 표시. */ + const buildDivider = (labelKey: keyof typeof ui_locales): HTMLElement => { + const divider = document.createElement("p"); + divider.className = "b06-std__divider"; + divider.textContent = L(labelKey); + return divider; + }; + + const buildScope = (scope: FieldScope): HTMLElement => { const grid = document.createElement("div"); grid.className = "b06-std__grid"; for (const spec of FIELD_SPECS) { - if (spec.only && spec.only !== key) continue; - const field = createInputField({ - label: L(spec.label as keyof typeof ui_locales), - type: "number", - value: String(spec.get(group)), - onInput: (raw) => { - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return; - spec.set(group, parsed); - persist(); - }, - }); - field.input.step = "0.1"; - field.input.min = "0"; - grid.append(field.root); + if (spec.scope === scope) buildField(spec, grid); } - fieldset.append(grid); + return grid; + }; - if (key === "rock") { - const note = document.createElement("p"); - note.className = "b06-std__note"; - note.textContent = L("B06_Std_LType_Note"); - fieldset.append(note); - } + /** 「표준횡단면 상세값」 한 컨테이너 — 공통 → 암 → 포장 순, 구분선으로 나눈다. */ + const buildDetails = (): HTMLElement => { + const fieldset = document.createElement("details"); + fieldset.className = "b06-std__group"; + fieldset.open = true; + const legend = document.createElement("summary"); + legend.className = "b06-std__legend"; + legend.textContent = L("B06_Std_Detail_Title"); + const note = document.createElement("p"); + note.className = "b06-std__note"; + note.textContent = L("B06_Std_LType_Note"); + fieldset.append( + legend, + buildScope("common"), + buildDivider("B06_Std_Section_RockOnly"), + buildScope("rock"), + buildDivider("B06_Std_Section_PavedOnly"), + buildScope("paved"), + note, + ); return fieldset; }; const renderBody = (): void => { - body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey))); + body.replaceChildren(buildDetails()); }; + // 옛 저장분이 그룹마다 다른 공통값을 갖고 있으면 토사 기준으로 한 벌로 맞춘다. + unifyCommon(state); + persist(); renderBody(); /** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */ @@ -236,6 +373,7 @@ export function createStandardPanel( (Object.keys(source) as StandardCrossKey[]).forEach((key) => { if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup; }); + unifyCommon(state); persist(); renderBody(); }; @@ -252,6 +390,7 @@ export function createStandardPanel( (Object.keys(fresh) as StandardCrossKey[]).forEach((key) => { state[key] = fresh[key]; }); + unifyCommon(state); persist(); renderBody(); }, @@ -282,6 +421,7 @@ export function createStandardPanel( (Object.keys(stored) as StandardCrossKey[]).forEach((key) => { if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup; }); + unifyCommon(state); renderBody(); }, }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index bc473aed..e405ef0a 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -287,6 +287,17 @@ gap: var(--spacing-8); } +/* 구간 구분선 — 「표준횡단면 상세값」 한 컨테이너 안에서 공통 / 암 / 포장을 가른다 + (2026-09-04 사용자 지시: 공통 항목은 지우고 구분선을 쓸 것). */ +.b06-std__divider { + margin: var(--spacing-4) 0 0; + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + .b06-std__note { margin: 0; font-size: var(--text-caption); diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0a3d4b5c..b2b89cd1 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -232,6 +232,18 @@ flex: 0 0 auto; } +/* 사면 미교차 경고 — 면적이 계산 반폭에서 잘린 측점(2026-09-03). */ +.b06-cross-card__warning { + color: var(--color-warning); + white-space: nowrap; +} + +/* 성토사면 경사길이 — 제목행 우측 끝(2026-09-03). meta 의 마지막 칸이라 우측 맞춤이다. */ +.b06-cross-card__fillslope { + color: var(--color-text-muted); + white-space: nowrap; +} + /* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */ .b06-design__seg--header { flex: 0 0 auto; @@ -294,6 +306,12 @@ fill: var(--color-surface-raised); } +/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은 + 그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */ +.b06-chart__ywindow[transform] * { + vector-effect: non-scaling-stroke; +} + .b06-chart__grid { stroke: var(--color-border); stroke-width: 1; @@ -554,3 +572,38 @@ border-radius: var(--radius-inputs); padding: 1px 4px; } + +/* 측점별 암 절토 경사(2026-09-07) — 암 경계선이 행 가운데를 절대 배치로 쓰므로 + 이 칸은 행의 **오른쪽 끝**에 둔다. 중심고(왼쪽)와 겹치지 않는다. */ +.b06-cross-card__cutslope { + margin-left: auto; + flex: 0 0 auto; + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +.b06-design__cutslope-input { + width: 44px; + padding: 0 2px; + font-size: 0.72rem; + font-family: var(--font-mono); + text-align: right; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); +} + +/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */ +.b06-design__cutslope-input::-webkit-outer-spin-button, +.b06-design__cutslope-input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.b06-design__cutslope-unit { + padding: 0 2px 0 1px; + font-size: 0.72rem; + color: var(--color-text-secondary); + align-self: center; +} 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 accd314c..34ab74a7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -5,7 +5,17 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; export interface DesignDrawingItem { id: string; // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. - kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; + kind: + | "cover" + | "longitudinal" + | "cross" + | "mass_haul" + | "watershed" + | "plan" + | "landuse" + | "plan_lidar" + | "cross_standard" + | "blank"; label: string; chainage_m: number | null; confirmed: boolean; @@ -80,7 +90,17 @@ export interface DesignDrawingResponse { route_id: number; id: string; // blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다. - kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank"; + kind: + | "cover" + | "longitudinal" + | "cross" + | "mass_haul" + | "watershed" + | "plan" + | "landuse" + | "plan_lidar" + | "cross_standard" + | "blank"; label: string; drawing: CadDrawing; confirmed: boolean; @@ -155,6 +175,8 @@ export interface FrameTemplateResponse { drawing: CadDrawing; /** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */ customized: boolean; + /** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */ + fields?: Record; } export function fetchFrameTemplate(projectId: string): Promise { @@ -168,6 +190,51 @@ export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promi }); } +/** 외부 도각 파일(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_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 14e47164..04b121aa 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -264,6 +264,21 @@ def infer_station_interval(stations: list[dict[str, Any]]) -> float: return max(counts.items(), key=lambda pair: (pair[1], pair[0]))[0] +def station_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str: + """납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`). + + 종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정). + """ + safe = interval_m if interval_m > 0 else 1.0 + number = int((chainage_m + 1e-6) // safe) + remainder = chainage_m - number * safe + if remainder >= safe - 0.05: + number += 1 + remainder = 0.0 + gap = " " if decimals == 1 else "" + return f"{number}+{gap}{remainder:.{decimals}f}" + + def station_no_label(chainage_m: float, interval_m: float) -> str: """납품 도면 측점 표기: No.n (비정규 측점은 No.n+잔여거리).""" safe = interval_m if interval_m > 0 else 1.0 @@ -428,6 +443,7 @@ def build_cross_drawing( design_elevation_m: float | None = None, frame: dict[str, float] | None = None, origin: tuple[float, float] = (0.0, 0.0), + cell_frame: tuple[float, float, float, float] | None = None, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. @@ -435,6 +451,10 @@ def build_cross_drawing( 설계선·구조물이 원지반과 갈라지는 구간 + 여유다. 세로는 이 단면 선들의 bbox 중심을 0에 둔다(측점마다 화면 중앙 정렬). design_elevation_m는 현재 배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다. + + cell_frame(왼쪽, 아래, 오른쪽, 위 — 종이 mm)을 주면 테두리를 그 칸에 맞춰 + 그린다. 장 배치에서 한 장 안의 칸을 같은 크기로 통일할 때 쓴다(2026-09-04 + 사용자 확정 — 축척 1/100은 그대로, 칸만 통일). """ ox, oy = origin raw_ground = points_from_samples(source.get("samples", []), "offset_m") @@ -496,16 +516,20 @@ def build_cross_drawing( ) table_bottom = table_top - cross_table_height() - # 외곽 테두리: 단면 범위와 표를 함께 감싼다. - frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) - frame_top = oy + half_height + 4.0 - frame_bottom = table_bottom - 4.0 + # 외곽 테두리: 단면 범위와 표를 함께 감싼다. 칸 크기를 받았으면 그 칸에 맞춘다. + if cell_frame is not None: + frame_left, frame_bottom, frame_right, frame_top = cell_frame + else: + frame_x = max((x1 - x0) / 2.0 * CROSS_MM + 4.0, cross_table_width() / 2.0 + 4.0) + frame_left, frame_right = center_x - frame_x, center_x + frame_x + frame_top = oy + half_height + 4.0 + frame_bottom = table_bottom - 4.0 corners = [ - (center_x - frame_x, frame_bottom), - (center_x + frame_x, frame_bottom), - (center_x + frame_x, frame_top), - (center_x - frame_x, frame_top), - (center_x - frame_x, frame_bottom), + (frame_left, frame_bottom), + (frame_right, frame_bottom), + (frame_right, frame_top), + (frame_left, frame_top), + (frame_left, frame_bottom), ] border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) if border: @@ -527,12 +551,7 @@ def build_cross_drawing( "x1": x1, # 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 # 서버 설계선을 이 안에서만 골라내는 데 쓴다. - "frame": [ - center_x - frame_x, - frame_bottom, - center_x + frame_x, - frame_top, - ], + "frame": [frame_left, frame_bottom, frame_right, frame_top], } ], "layers": [ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index e79f7336..271f75cd 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -28,7 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _layer, _text_entity, polyline_entity, - station_no_label, + station_plus_label, table_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( @@ -226,7 +226,8 @@ def _info_box_entities( 선과 문자를 따로 두지 않는다 — 표로 두어야 나중에 DXF의 표로 나갈 수 있다. """ - station = station_no_label(_number(props.get("chainage_m")), interval_m) + # 측점 표기는 납품 도면과 같은 `n+ 0.0` 형식(2026-09-03 사용자 확정 — 토적도와 통일). + station = station_plus_label(_number(props.get("chainage_m")), interval_m) values = { "area": f"{_number(props.get('area_m2')) / 10000.0:.2f} ha", "relief": f"{_format(_number(props.get('relief_m')), 1)} m", diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py new file mode 100644 index 00000000..1949a66f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py @@ -0,0 +1,339 @@ +"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다. + +사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고 +연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를 +넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」. + +지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`· +`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 이번 판은 **지번만** 적는다 — +지목·면적·소유 구분은 용지 조서(표)에서 쓸 값이라 도면에는 넣지 않는다. + +축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다. + +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). +""" + +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _COMPASS_MARGIN, + _COMPASS_SIZE, + _FONT_SIZE, + _TITLE_FONT_SIZE, + MM, + plan_area_mm, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import DRAWING_SCALE_PLAN + +LANDUSE_KIND = "landuse" +LANDUSE_LABEL = "용지도" + +CONTOUR_LAYER_ID = "b07-landuse-contour" +PARCEL_LAYER_ID = "b07-landuse-parcel" +JIBUN_LAYER_ID = "b07-landuse-jibun" +EMD_LAYER_ID = "b07-landuse-emd" +SGG_LAYER_ID = "b07-landuse-sgg" +ROUTE_LAYER_ID = "b07-landuse-route" +LEGEND_LAYER_ID = "b07-landuse-legend" +TITLE_LAYER_ID = "b07-landuse-title" + +# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경 +# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」). +CONTOUR_COLOR = "#9aa3ad" +PARCEL_COLOR = "#8c6b4f" +JIBUN_COLOR = "#5c4632" +EMD_COLOR = "#2f7d4f" +SGG_COLOR = "#a63d3d" +ROUTE_COLOR = "#ffe066" +LEGEND_COLOR = TABLE_LABEL_COLOR + +_ROUTE_WIDTH = 3 +_SGG_WIDTH = 3 +_EMD_WIDTH = 2 +_JIBUN_FONT_SIZE = 1.8 +_LEGEND_FONT_SIZE = 2.4 +# 지번을 적을 최소 필지 크기(종이 mm) — 이보다 작으면 글자가 겹쳐 읽히지 않는다. +_JIBUN_MIN_W_MM = 6.0 +_JIBUN_MIN_H_MM = 3.0 + +_LEGEND_ROW_H = 6.0 +_LEGEND_SAMPLE_W = 12.0 +_LEGEND_GAP = 3.0 +_LEGEND_TOP_GAP = 8.0 + +# 범례 항목 (표기 이름, 색, 선굵기, 파선). +_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = ( + ("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None), + ("필지 경계", PARCEL_COLOR, 1, None), + ("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]), + ("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]), + ("등고선", CONTOUR_COLOR, 1, None), +) + + +def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]: + """고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다.""" + xs = [x for x, _y in ring] + ys = [y for _x, y in ring] + return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) + + +def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]: + """범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다.""" + entities: list[dict[str, Any]] = [] + x, y = origin + entities.append( + _text_entity( + f"{drawing_id}:legend:title", + "범 례", + x + _LEGEND_SAMPLE_W / 2.0 + 6.0, + y, + LEGEND_LAYER_ID, + _LEGEND_FONT_SIZE + 0.6, + LEGEND_COLOR, + ) + ) + for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS): + row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H + sample = polyline_entity( + drawing_id, + [(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)], + LEGEND_LAYER_ID, + color, + suffix=f":legend:{index}", + dash=dash, + width=width, + ) + if sample: + entities.append(sample) + entities.append( + _text_entity( + f"{drawing_id}:legend:label:{index}", + label, + x + _LEGEND_SAMPLE_W + _LEGEND_GAP, + row_y, + LEGEND_LAYER_ID, + _LEGEND_FONT_SIZE, + LEGEND_COLOR, + align="left", + ) + ) + return entities + + +def _boundary_entities( + drawing_id: str, + rings: list[list[tuple[float, float]]], + layer_id: str, + color: str, + width: int, + dash: list[int] | None, + paper: Any, + tag: str, +) -> list[dict[str, Any]]: + entities: list[dict[str, Any]] = [] + for index, ring in enumerate(rings): + line = polyline_entity( + drawing_id, + [paper(point) for point in ring], + layer_id, + color, + suffix=f":{tag}:{index}", + dash=dash, + width=width, + ) + if line: + entities.append(line) + return entities + + +def build_landuse_drawing( + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + contours: list[list[tuple[float, float]]], + parcels: list[dict[str, Any]], + emd_rings: list[list[tuple[float, float]]], + sgg_rings: list[list[tuple[float, float]]], +) -> dict[str, Any]: + """용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. + + `parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는 + 라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다. + """ + everything = [ + *route_xy, + *(point for line in contours for point in line), + *(point for parcel in parcels for point in parcel.get("ring") or []), + ] + if not everything: + raise FileNotFoundError( + "용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + # 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다. + entities.extend( + _boundary_entities( + drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour" + ) + ) + # 필지 경계 + 지번. + jibun: list[dict[str, Any]] = [] + for index, parcel in enumerate(parcels): + ring = parcel.get("ring") or [] + label_at = parcel.get("label_at") + if len(ring) >= 2: + outline = polyline_entity( + drawing_id, + [paper(point) for point in ring], + PARCEL_LAYER_ID, + PARCEL_COLOR, + suffix=f":parcel:{index}", + ) + if outline: + entities.append(outline) + elif label_at is None: + continue + if label_at is not None: + # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 지번만 적는다. + center = paper(tuple(label_at)) + else: + paper_ring = [paper(point) for point in ring] + width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring) + height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring) + # 작은 필지는 지번을 솎는다 — 글자가 겹치면 큰 필지 것까지 못 읽는다. + if width < _JIBUN_MIN_W_MM or height < _JIBUN_MIN_H_MM: + continue + center = _ring_center(paper_ring) + text = (parcel.get("props") or {}).get("jibun") + if not isinstance(text, str) or not text: + continue + jibun.append( + _text_entity( + f"{drawing_id}:jibun:{index}", + text, + center[0], + center[1], + JIBUN_LAYER_ID, + _JIBUN_FONT_SIZE, + JIBUN_COLOR, + ) + ) + # 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다. + entities.extend( + _boundary_entities( + drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd" + ) + ) + entities.extend( + _boundary_entities( + drawing_id, + sgg_rings, + SGG_LAYER_ID, + SGG_COLOR, + _SGG_WIDTH, + [14, 5, 3, 5], + paper, + "sgg", + ) + ) + map_bbox = entities_bbox(entities) + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + entities.extend(jibun) # 지번은 가장 위 — 선에 가리면 못 읽는다. + + # 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리). + if map_bbox: + column_x = map_bbox[2] + _COMPASS_MARGIN + column_top = map_bbox[3] + entities.extend( + compass_entities( + drawing_id, + (column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0), + _COMPASS_SIZE, + ) + ) + entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0))) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(CONTOUR_LAYER_ID, "등고선", locked=True), + _layer(PARCEL_LAYER_ID, "필지 경계"), + _layer(JIBUN_LAYER_ID, "지번"), + _layer(EMD_LAYER_ID, "읍면동·리 경계"), + _layer(SGG_LAYER_ID, "시군구 경계"), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(LEGEND_LAYER_ID, "범례"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } + + +def landuse_area_mm() -> tuple[float, float]: + """지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽).""" + return plan_area_mm() diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py new file mode 100644 index 00000000..726061c1 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Lidar.py @@ -0,0 +1,220 @@ +"""B07 계획평면도(라이다) CAD 조립 — 지표면 격자를 음영기복 그림으로 깔고 노선을 얹는다. + +사용자 지시(2026-09-04) — 「라이다 계획평면도는 3D 자료를 탑뷰에서 본 그림이 필요함. +가능한 범위에서 일단 배치해 주면 보고 개선하겠음」. + +점구름을 그대로 그리면 수천만 점이라 도면 만들기가 느려진다(용화_LAS 실측 4,900만 점). +이미 만들어 둔 **지표면 격자(DTM)** 로 음영기복 이미지를 서버에서 만들어 배경으로 깐다. +도면 틀이 이미지 요소를 받아 주므로(`Image` 엔티티) PNG 를 그대로 싣는다. + +축척·도곽·장 나눔은 계획평면도와 같다(1/1,200 고정) — 같은 자리에 노선이 서야 한다. +""" + +import base64 +import io +import math +from typing import Any + +import numpy as np + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + _COMPASS_MARGIN, + _COMPASS_SIZE, + _FONT_SIZE, + _ROUTE_WIDTH, + _TITLE_FONT_SIZE, + MM, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import DRAWING_SCALE_PLAN + +LIDAR_KIND = "plan_lidar" +LIDAR_LABEL = "계획평면도(라이다)" + +SHADE_LAYER_ID = "b07-lidar-shade" +ROUTE_LAYER_ID = "b07-lidar-route" +TITLE_LAYER_ID = "b07-lidar-title" +ROUTE_COLOR = "#ffe066" + +# 음영기복 광원 — 도면 관행대로 북서(방위각 315°)에서 45° 높이로 비춘다. +_AZIMUTH_DEG = 315.0 +_ALTITUDE_DEG = 45.0 +# 그림이 지나치게 커지지 않도록 한 변 최대 픽셀 수 (A1 에 인쇄하면 1,200 px 이면 충분하다). +_MAX_PIXELS = 1600 + + +def hillshade_png(z: np.ndarray, valid: np.ndarray, resolution_m: float) -> tuple[str, int, int]: + """지표면 격자에서 음영기복 PNG(data URL)를 만든다. (data_url, 가로 px, 세로 px). + + 입력 `z`는 행이 남→북 순서(격자 y 오름차순)다. 그림은 위가 북이어야 하므로 뒤집는다. + 빈 칸(`valid`가 False)은 흰색으로 두어 도면에서 배경과 구분되게 한다. + """ + from PIL import Image + + grid = np.asarray(z, dtype=np.float64) + mask = np.asarray(valid, dtype=bool) + if grid.ndim != 2 or grid.size == 0: + raise ValueError("지표면 격자가 비어 있습니다.") + + # 큰 격자는 미리 솎는다 — A1 한 장에 1,600 px 이상은 눈으로 구분되지 않는다. + rows, columns = grid.shape + stride = max(1, math.ceil(max(rows, columns) / _MAX_PIXELS)) + if stride > 1: + grid = grid[::stride, ::stride] + mask = mask[::stride, ::stride] + resolution_m *= stride + + filled = np.where(mask, grid, np.nan) + # 빈 칸이 기울기를 망치지 않도록 평균으로 메운 뒤 기울기를 잰다. + mean = float(np.nanmean(filled)) if np.isfinite(filled).any() else 0.0 + filled = np.nan_to_num(filled, nan=mean) + dz_dy, dz_dx = np.gradient(filled, max(resolution_m, 1e-6)) + + slope = np.arctan(np.hypot(dz_dx, dz_dy)) + aspect = np.arctan2(-dz_dx, dz_dy) + azimuth = math.radians(360.0 - _AZIMUTH_DEG + 90.0) + altitude = math.radians(_ALTITUDE_DEG) + shade = np.sin(altitude) * np.cos(slope) + np.cos(altitude) * np.sin(slope) * np.cos( + azimuth - aspect + ) + shade = np.clip(shade, 0.0, 1.0) + # 배경이므로 완전히 검지 않게 누르되, 능선·계곡이 인쇄에서 보일 만큼은 대비를 준다 + # (2026-09-04 실측: 120~255 는 너무 흐렸음). + pixels = (90 + 160 * shade).astype(np.uint8) + pixels[~mask] = 255 + + image = Image.fromarray(np.flipud(pixels), mode="L") + buffer = io.BytesIO() + image.save(buffer, format="PNG", optimize=True) + data_url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") + return (data_url, image.width, image.height) + + +def build_lidar_plan_drawing( + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + shade_image: str | None, + shade_box: tuple[float, float, float, float] | None, +) -> dict[str, Any]: + """라이다 계획평면도 한 장을 만든다. + + `shade_box`는 음영기복 그림이 덮는 실좌표 범위(min_x, min_y, max_x, max_y)다 — + 그림 네 모서리를 그 범위 그대로 종이에 놓아야 노선과 좌표가 맞는다. + """ + everything = [*route_xy] + if shade_box: + everything.extend([(shade_box[0], shade_box[1]), (shade_box[2], shade_box[3])]) + if not everything: + raise FileNotFoundError( + "라이다 계획평면도에 그릴 자료가 없습니다. B04 전처리에서 지표면을 먼저 만드세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + if shade_image and shade_box: + left, bottom = paper((shade_box[0], shade_box[1])) + right, top = paper((shade_box[2], shade_box[3])) + entities.append( + { + "id": f"{drawing_id}:shade", + "type": "Image", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": SHADE_LAYER_ID, + "shapeData": { + "points": [ + {"x": left, "y": bottom}, + {"x": right, "y": bottom}, + {"x": right, "y": top}, + {"x": left, "y": top}, + ], + "imageData": shade_image, + }, + } + ) + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + + map_bbox = entities_bbox(entities) + if map_bbox: + entities.extend( + compass_entities( + drawing_id, + ( + map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, + map_bbox[3] - _COMPASS_SIZE / 2.0, + ), + _COMPASS_SIZE, + ) + ) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(SHADE_LAYER_ID, "지표면 음영기복", locked=True), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index e9b37c2c..18df4f9b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -13,7 +13,8 @@ (정의처 `common_util_mass_haul.massHaulPayload`)을 그대로 읽어 좌표만 종이 mm로 바꾼다 — 곡선 보간·토량 배분 로직을 파이썬에 복제하지 않는다. -좌표 규약: x = 누가거리(m) x MM_H, y = 누가토량(㎥) / 종이 1mm당 토량. +좌표 규약: x = 누가거리(m) x mm_h, y = 누가토량(㎥) / 종이 1mm당 토량. +가로 축척(mm_h)은 노선 연장으로 정한다 — `auto_scale_h()`. """ import math @@ -32,7 +33,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, infer_station_interval, polyline_entity, - station_no_label, + station_plus_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, @@ -40,22 +41,45 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( scale_fields, ) from config.config_system import ( + DRAWING_MASSHAUL_USABLE_WIDTH_MM, DRAWING_SCALE_MASSHAUL_H, + DRAWING_SCALE_MASSHAUL_H_CANDIDATES, DRAWING_SCALE_MASSHAUL_V_M3_MM, ) -# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/2,000 -> 0.5). -MM_H = 1000.0 / DRAWING_SCALE_MASSHAUL_H -# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). +# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). 세로는 고정이다 — 축척 분모가 +# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다. MM_V = 1.0 / DRAWING_SCALE_MASSHAUL_V_M3_MM + +def auto_scale_h(length_m: float) -> int: + """노선 연장에 맞는 가로 축척 분모 — **한 장에 들어가는 가장 큰 그림**을 고른다. + + 2026-09-03 사용자 결정(길이별 자동 축척). 후보는 도면 관행 축척뿐이고, 가장 큰 후보로도 + 안 들어가면 그 값을 쓴다(도각 템플릿이 콘텐츠에 맞춰 늘어나므로 잘리지는 않는다). + """ + if length_m <= 0: + return DRAWING_SCALE_MASSHAUL_H + for denominator in sorted(DRAWING_SCALE_MASSHAUL_H_CANDIDATES): + if length_m * 1000.0 / denominator <= DRAWING_MASSHAUL_USABLE_WIDTH_MM: + return denominator + return max(DRAWING_SCALE_MASSHAUL_H_CANDIDATES) + + CURVE_LAYER_ID = "b08-masshaul-curve" CURVE_COLOR = "#ff66ff" AXIS_LAYER_ID = "b08-masshaul-axis" AXIS_COLOR = "#ff4d4d" BAND_LAYER_ID = "b08-masshaul-band" BAND_COLOR = "#ffe066" -BALANCE_COLOR = "#e8edf4" +# 평형선·띠 경계현은 **빨강** — 납품 도면 표기(2026-09-03 사용자 확정). balloon·문자는 +# 종전 노랑 그대로다. +BALANCE_COLOR = "#ff4d4d" +BAND_CHORD_COLOR = "#ff4d4d" +# 표 눈금: 정규 측점은 빨강, 그 사이 추가 측점은 회색(납품 도면 표기). +TABLE_TICK_COLOR = "#ff4d4d" +TABLE_TICK_EXTRA_COLOR = "#9aa5a0" +_TABLE_TICK_LEN = 2.0 TABLE_LAYER_ID = "b08-masshaul-table" # 세로축 눈금 간격(㎥) — 5,000㎥ = 종이 100 mm. @@ -75,7 +99,7 @@ _BALLOON_STAGGER = 3 # 겹침 회피용 층 수 # 장비 키 → 도면 표기(실무 수량산출 용어: 무대·도자·덤프). EQUIPMENT_LABEL = { - "free_haul": "무대", + "free_haul": "종무대", "dozer": "도자", "dump_truck": "덤프", } @@ -86,6 +110,7 @@ _TABLE_ROWS: tuple[tuple[str, str, float], ...] = ( ("station", "측점", 7.0), ) _TABLE_LABEL_WIDTH = 16.0 # 좌측 행 이름 칸 폭(mm) +_TABLE_LABEL_FONT_SIZE = 3.2 # 행 이름은 본문보다 크게(납품 도면 표기) _TABLE_TOP_GAP = 6.0 # 그래프 최저점과 테이블 사이 간격(mm) @@ -105,8 +130,8 @@ def _curve_points(mass_haul: dict[str, Any]) -> list[tuple[float, float]]: ] -def _paper(x_m: float, volume_m3: float) -> tuple[float, float]: - return (x_m * MM_H, volume_m3 * MM_V) +def _paper(x_m: float, volume_m3: float, mm_h: float) -> tuple[float, float]: + return (x_m * mm_h, volume_m3 * MM_V) def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) -> float: @@ -120,15 +145,15 @@ def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> list[dict[str, Any]]: """좌측 세로축(빨강)·눈금·라벨 + 누가토량 0 기준선.""" entities: list[dict[str, Any]] = [] - top = _paper(0.0, max_v)[1] - bottom = _paper(0.0, min_v)[1] + top = max_v * MM_V + bottom = min_v * MM_V entities.append( _line_entity(f"{drawing_id}:axis:v", (x0, bottom), (x0, top), AXIS_LAYER_ID, AXIS_COLOR) ) start = int(min_v // AXIS_TICK_M3) * AXIS_TICK_M3 value = start while value <= max_v + 1e-6: - y = _paper(0.0, value)[1] + y = value * MM_V entities.append( _line_entity( f"{drawing_id}:axis:tick:{value:.0f}", @@ -202,17 +227,19 @@ def _balloon_entities( ) if shape_entity: entities.append(shape_entity) - # 지시선: balloon 아래(또는 위) 가장자리 → 띠 현 중앙. - edge_y = cy - half_h if anchor[1] < cy else cy + half_h - entities.append( - _line_entity( - f"{drawing_id}:balloon:leader:{seed}", - (cx, edge_y), - anchor, - BAND_LAYER_ID, - BAND_COLOR, - ) + # 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 — + # 납품 도면 표기). 종전에는 아래 가장자리 중앙에서 대각선 하나로 갔다. + corner_y = cy - half_h if anchor[1] < cy else cy + half_h + corner_x = cx - half_w if anchor[0] < cx else cx + half_w + leader = polyline_entity( + drawing_id, + [(corner_x, corner_y), (anchor[0], corner_y), anchor], + BAND_LAYER_ID, + BAND_COLOR, + suffix=f":balloon:leader:{seed}", ) + if leader: + entities.append(leader) first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75 for index, line in enumerate(lines): entities.append( @@ -230,7 +257,7 @@ def _balloon_entities( def _band_entities( - drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]] + drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]], mm_h: float ) -> list[dict[str, Any]]: """블록 평형선·띠 경계현·띠 balloon.""" entities: list[dict[str, Any]] = [] @@ -247,8 +274,8 @@ def _band_entities( entities.append( _line_entity( f"{drawing_id}:block:{block.get('index')}", - _paper(from_m, base_m3), - _paper(to_m, base_m3), + _paper(from_m, base_m3, mm_h), + _paper(to_m, base_m3, mm_h), BAND_LAYER_ID, BALANCE_COLOR, ) @@ -266,20 +293,20 @@ def _band_entities( entities.append( _line_entity( f"{drawing_id}:band:boundary:{index}", - _paper(boundary_from, level_base), - _paper(boundary_to, level_base), + _paper(boundary_from, level_base, mm_h), + _paper(boundary_to, level_base, mm_h), BAND_LAYER_ID, - BAND_COLOR, + BAND_CHORD_COLOR, ) ) mid_level = (level_base + level_apex) / 2.0 entities.append( _line_entity( f"{drawing_id}:band:haul:{index}", - _paper(haul_from, mid_level), - _paper(haul_to, mid_level), + _paper(haul_from, mid_level, mm_h), + _paper(haul_to, mid_level, mm_h), BAND_LAYER_ID, - BAND_COLOR, + BAND_CHORD_COLOR, ) ) equipment = str(band.get("equipment") or "") @@ -288,18 +315,16 @@ def _band_entities( # 않아 적지 않는다 (2026-08-30 사용자 확정 — 확인되면 그때 붙인다). lines = [ label, - f"Q={_format(_number(band.get('volume_m3')))}M3", - f"L={_format(_number(band.get('haul_distance_m')))}M", - f"EA={_format(_number(band.get('ea_m3')))}M3", - f"RR={_format(_number(band.get('rr_m3')))}M3", - f"BR={_format(_number(band.get('br_m3')))}M3", + f"Q= {_format(_number(band.get('volume_m3')))}M3", + f"L= {_format(_number(band.get('haul_distance_m')))}M", + f"EA= {_format(_number(band.get('ea_m3')))}M3", + f"RR= {_format(_number(band.get('rr_m3')))}M3", + f"BR= {_format(_number(band.get('br_m3')))}M3", ] - anchor = _paper((haul_from + haul_to) / 2.0, mid_level) + anchor = _paper((haul_from + haul_to) / 2.0, mid_level, mm_h) top_m3 = _curve_top_at(curve, boundary_from, boundary_to) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y - center_y = ( - _paper(0.0, top_m3)[1] + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) - ) + center_y = top_m3 * MM_V + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) entities.extend( _balloon_entities( drawing_id, @@ -319,6 +344,7 @@ def _residual_entities( plan: dict[str, Any], curve: list[tuple[float, float]], interval_m: float, + mm_h: float, ) -> list[dict[str, Any]]: """사토·토취 balloon — 운반거리 대신 발생 측점(M.N)을 적는다.""" entities: list[dict[str, Any]] = [] @@ -334,20 +360,18 @@ def _residual_entities( from_m = _number(residual.get("from_m")) to_m = _number(residual.get("to_m"), from_m) level = _number(residual.get("level_from_m3")) - station = station_no_label(from_m, interval_m).removeprefix("No.") + station = station_plus_label(from_m, interval_m, decimals=2) lines = [ f"{kind} {index}", - f"Q={_format(_number(residual.get('volume_m3')))}M3", - f"M.N={station}", - f"EA={_format(_number(residual.get('ea_m3')))}M3", - f"RR={_format(_number(residual.get('rr_m3')))}M3", - f"BR={_format(_number(residual.get('br_m3')))}M3", + f"Q= {_format(_number(residual.get('volume_m3')))}M3", + f"M.N= {station}", + f"EA= {_format(_number(residual.get('ea_m3')))}M3", + f"RR= {_format(_number(residual.get('rr_m3')))}M3", + f"BR= {_format(_number(residual.get('br_m3')))}M3", ] - anchor = _paper((from_m + to_m) / 2.0, level) + anchor = _paper((from_m + to_m) / 2.0, level, mm_h) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y - center_y = ( - _paper(0.0, bottom_m3)[1] - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) - ) + center_y = bottom_m3 * MM_V - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) entities.extend( _balloon_entities( drawing_id, f"residual:{index}", lines, anchor, (anchor[0], center_y), "rect" @@ -362,6 +386,7 @@ def _table_entities( stations: list[dict[str, Any]], interval_m: float, top_y: float, + mm_h: float, ) -> list[dict[str, Any]]: """하단 2행 테이블(누가토량 / 측점). 값은 세로쓰기, 측점은 No. 표기.""" entities: list[dict[str, Any]] = [] @@ -369,9 +394,10 @@ def _table_entities( return entities cumulative = {round(x, 3): v for x, v in curve} xs = [x for x, _v in curve] - left = min(xs) * MM_H - _TABLE_LABEL_WIDTH - right = max(xs) * MM_H + left = min(xs) * mm_h - _TABLE_LABEL_WIDTH + right = max(xs) * mm_h + safe_interval = interval_m if interval_m > 0 else 1.0 y = top_y boundaries = [y] for _key, _label, height in _TABLE_ROWS: @@ -405,21 +431,21 @@ def _table_entities( entities.append( _text_entity( f"{drawing_id}:table:name:{key}", - label, + " ".join(label), left + _TABLE_LABEL_WIDTH / 2.0, center_y, TABLE_LAYER_ID, - _FONT_SIZE, + _TABLE_LABEL_FONT_SIZE, TABLE_LABEL_COLOR, ) ) for station in stations: chainage = _number(station.get("chainage_m")) - x = chainage * MM_H + x = chainage * mm_h if x < left + _TABLE_LABEL_WIDTH or x > right: continue if key == "station": - text = station_no_label(chainage, interval_m) + text = station_plus_label(chainage, interval_m) y_text = center_y direction = _VERTICAL else: @@ -429,6 +455,19 @@ def _table_entities( text = _format(value) y_text = row_bottom + 0.8 direction = _VERTICAL + # 표 눈금 — 정규 측점(간격의 배수)은 빨강, 그 사이 추가 측점은 회색. + if row_index == 0: + remainder = abs(chainage - round(chainage / safe_interval) * safe_interval) + regular = remainder < 0.05 + entities.append( + _line_entity( + f"{drawing_id}:table:tick:{chainage:.2f}", + (x, row_top), + (x, row_top - _TABLE_TICK_LEN), + TABLE_LAYER_ID, + TABLE_TICK_COLOR if regular else TABLE_TICK_EXTRA_COLOR, + ) + ) entities.append( _text_entity( f"{drawing_id}:table:{key}:{chainage:.2f}", @@ -457,38 +496,45 @@ def build_mass_haul_drawing( interval_m = infer_station_interval(all_stations) volumes = [v for _x, v in curve] min_v, max_v = min(volumes), max(volumes) + # 가로 축척은 노선 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림 + # (2026-09-03 사용자 결정: 길이별 자동 축척). + length_m = max(x for x, _v in curve) - min(x for x, _v in curve) + scale_h = auto_scale_h(length_m) + mm_h = 1000.0 / scale_h entities: list[dict[str, Any]] = [] - x0 = min(x for x, _v in curve) * MM_H + x0 = min(x for x, _v in curve) * mm_h entities.extend(_axis_entities(drawing_id, x0, min(min_v, 0.0), max(max_v, 0.0))) entities.append( _line_entity( f"{drawing_id}:axis:zero", (x0, 0.0), - (max(x for x, _v in curve) * MM_H, 0.0), + (max(x for x, _v in curve) * mm_h, 0.0), AXIS_LAYER_ID, AXIS_COLOR, ) ) curve_entity = polyline_entity( - drawing_id, [_paper(x, v) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR + drawing_id, [_paper(x, v, mm_h) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR ) if curve_entity: entities.append(curve_entity) plan = mass_haul.get("haul_plan") if isinstance(plan, dict): - entities.extend(_band_entities(drawing_id, plan, curve)) - entities.extend(_residual_entities(drawing_id, plan, curve, interval_m)) + entities.extend(_band_entities(drawing_id, plan, curve, mm_h)) + entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h)) # 테이블은 그래프·balloon 어느 것보다도 아래에 둔다 — 사토·토취 balloon이 곡선 밑에 # 깔리므로 그래프 최저점만 보고 자리를 잡으면 표와 겹친다(2026-08-30 화면 실측). - graph_bottom = min(_paper(0.0, min_v)[1], 0.0) + graph_bottom = min(min_v * MM_V, 0.0) drawn = entities_bbox(entities) if drawn: graph_bottom = min(graph_bottom, drawn[1]) entities.extend( - _table_entities(drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP) + _table_entities( + drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP, mm_h + ) ) bbox = entities_bbox(entities) @@ -505,10 +551,7 @@ def build_mass_haul_drawing( TABLE_LABEL_COLOR, ) ) - scale_text = ( - f"SCALE H=1:{DRAWING_SCALE_MASSHAUL_H:,} " - f"V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" - ) + scale_text = f"SCALE H=1:{scale_h:,} V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" entities.append( _text_entity( f"{drawing_id}:scale", @@ -526,7 +569,7 @@ def build_mass_haul_drawing( drawing_id, entities_bbox(entities) or bbox, fit=False, - fields={"도면명": "토적도", **scale_fields(("H", DRAWING_SCALE_MASSHAUL_H))}, + fields={"도면명": "토적도", **scale_fields(("H", scale_h))}, ) ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py new file mode 100644 index 00000000..18da7be6 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Plan.py @@ -0,0 +1,388 @@ +"""B07 계획평면도 CAD 조립 — 수치등고선 배경 위에 노선·측점·구조물을 얹는다. + +세 장이 같은 배경·같은 축척을 쓰고 주제만 다르다(2026-09-04 사용자 지시). + + - 계획평면도(지형) : 등고선·세류선만 + - 계획평면도(노선배치도): 배경 + 계획노선 + 측점 + - 계획평면도(배치도) : 배경 + 계획노선 + 구조물 배치 + +배경 자료는 유역도와 **같은 창구**(`B07_DesignDetail_Router_Support_Basin.map_background`) +에서 온다 — 도엽 GeoJSON 읽기·좌표 환산은 한 번뿐이고 여러 도면이 그 결과를 나눠 쓴다. + +축척은 지식DB 「설계제원_총괄」 측량·도면 기준 **1/1,200 고정**이다. 횡단면도와 같은 +원칙으로, 한 장에 안 들어가면 축척을 줄이지 않고 **장을 나눈다**. + +좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). +""" + +import math +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, + station_plus_label, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + compass_entities, + entities_bbox, + frame_entities, + scale_fields, + usable_area, +) +from config.config_system import DRAWING_SCALE_PLAN + +# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/1,200 -> 0.8333). +MM = 1000.0 / DRAWING_SCALE_PLAN + +CONTOUR_LAYER_ID = "b07-plan-contour" +CONTOUR_COLOR = "#6b7684" +STREAM_LAYER_ID = "b07-plan-stream" +STREAM_COLOR = "#4d9dff" +ROUTE_LAYER_ID = "b07-plan-route" +ROUTE_COLOR = "#ffe066" +STATION_LAYER_ID = "b07-plan-station" +STATION_COLOR = "#ff9d4d" +STRUCTURE_LAYER_ID = "b07-plan-structure" +STRUCTURE_COLOR = "#ff4d4d" +TITLE_LAYER_ID = "b07-plan-title" + +_ROUTE_WIDTH = 3 +_TITLE_FONT_SIZE = 7.0 +_FONT_SIZE = 2.2 +_STATION_FONT_SIZE = 2.0 +_STATION_TICK_MM = 2.5 # 측점 눈금 반길이(종이 mm) +_STRUCTURE_SIZE_MM = 3.0 # 구조물 기호 반크기(종이 mm) +_STRUCTURE_FONT_SIZE = 2.2 +_TITLE_BAND = 22.0 # 제목·척도가 차지하는 위쪽 띠(mm) +_COMPASS_SIZE = 26.0 +_COMPASS_MARGIN = 12.0 + +# 세 장의 주제 (id 접두어, 도면명, 노선·측점·구조물을 그리는지). +PLAN_KINDS: tuple[tuple[str, str, bool, bool, bool], ...] = ( + ("plan_terrain", "계획평면도(지형)", False, False, False), + ("plan_route", "계획평면도(노선배치도)", True, True, False), + ("plan_layout", "계획평면도(배치도)", True, False, True), +) +PLAN_KIND_LABELS: dict[str, str] = {kind: label for kind, label, *_rest in PLAN_KINDS} + +# 구조물 종류별 표기 — pipe_points.json 의 facility 값 기준. +_FACILITY_LABELS: dict[str, str] = { + "ford_bridge": "세월교", + "box_culvert": "BOX암거", + "bridge": "교량", +} + + +def plan_area_mm() -> tuple[float, float]: + """지형 배경이 차지할 수 있는 크기(mm) — A1 작도영역에서 방위표 칸과 제목 띠를 뺀다. + + 라우터는 이 크기를 축척으로 되돌려 등고선·세류선 절취 범위를 잡는다(정의처 한 곳). + """ + width, height = usable_area() + return (width - (_COMPASS_MARGIN + _COMPASS_SIZE), height - _TITLE_BAND) + + +def _chunk_span_m() -> tuple[float, float]: + """한 장이 담을 수 있는 실거리(m) — 도곽 지형 영역을 축척으로 되돌린 크기.""" + area_w, area_h = plan_area_mm() + return (area_w * DRAWING_SCALE_PLAN / 1000.0, area_h * DRAWING_SCALE_PLAN / 1000.0) + + +def plan_chunks(stations: list[tuple[float, float, float]]) -> list[dict[str, Any]]: + """노선을 한 장에 들어가는 구간으로 나눈다. 각 항목: {number, start_m, end_m}. + + 입력은 종단 측점의 (누가거리 m, x, y)다 — **도면 목록과 도면 생성이 같은 자료**를 + 보아야 장수가 어긋나지 않는다(종단도 분할과 같은 방식). + + 축척 1/1,200 은 고정이므로 한 장에 안 들어가면 **노선을 따라 장을 나눈다** + (2026-09-04 — 횡단면도와 같은 원칙). 경계 측점 1개를 중복시켜 장 사이에서 노선이 + 끊겨 보이지 않게 한다(납품 도면 관례). + """ + ordered = sorted(stations, key=lambda item: item[0]) + if len(ordered) < 2: + span = (ordered[0][0] if ordered else 0.0, ordered[0][0] if ordered else 0.0) + return [{"number": 1, "start_m": span[0], "end_m": span[1]}] + span_w, span_h = _chunk_span_m() + + def fits(part: list[tuple[float, float, float]]) -> bool: + width = max(x for _c, x, _y in part) - min(x for _c, x, _y in part) + height = max(y for _c, _x, y in part) - min(y for _c, _x, y in part) + # 가로로 길든 세로로 길든 도곽에만 들어가면 된다 — 두 방향 다 본다. + return (width <= span_w and height <= span_h) or (width <= span_h and height <= span_w) + + chunks: list[dict[str, Any]] = [] + start = 0 + while start < len(ordered) - 1: + end = start + 1 + while end + 1 < len(ordered) and fits(ordered[start : end + 2]): + end += 1 + chunks.append( + { + "number": len(chunks) + 1, + "start_m": float(ordered[start][0]), + "end_m": float(ordered[end][0]), + } + ) + start = end # 경계 측점 1개 중복 + return chunks + + +def plan_drawing_id(kind: str, chunk: dict[str, Any], total: int) -> str: + """장이 하나면 접두어 그대로, 여럿이면 `plan_route_2` 처럼 번호를 붙인다.""" + return kind if total <= 1 else f"{kind}_{chunk['number']}" + + +def plan_drawing_label(kind: str, chunk: dict[str, Any], total: int) -> str: + label = PLAN_KIND_LABELS.get(kind, kind) + return label if total <= 1 else f"{label} {chunk['number']}장" + + +def _structure_label(structure: dict[str, Any]) -> str: + """구조물 표기 — 세월교·BOX암거는 이름, 배수관은 관경(mm).""" + facility = structure.get("facility") + if isinstance(facility, str) and facility in _FACILITY_LABELS: + return _FACILITY_LABELS[facility] + options = structure.get("options") + diameter = options.get("pipe_diameter_mm") if isinstance(options, dict) else None + return f"D{int(diameter)}" if isinstance(diameter, (int, float)) else "배수시설" + + +def _station_entities( + drawing_id: str, + stations: list[tuple[float, float, float]], + interval_m: float, + paper: Any, +) -> list[dict[str, Any]]: + """측점 눈금과 이름(No.n+00)을 노선 위에 직각으로 세운다. + + 입력은 **종단 측점**(누가거리 m, x, y)이다 — 노선 정점은 수백 개라 전부 찍으면 + 뭉개지고, 정점의 누가거리는 측점 간격의 배수가 아니라 걸러지지도 않는다 + (2026-09-04 실측: 눈금이 2개만 찍혔음). + """ + entities: list[dict[str, Any]] = [] + for index, (chainage, x, y) in enumerate(stations): + point = (x, y) + before = stations[max(index - 1, 0)] + after = stations[min(index + 1, len(stations) - 1)] + dx, dy = after[1] - before[1], after[2] - before[2] + length = math.hypot(dx, dy) or 1.0 + # 노선 진행 방향의 법선 — 눈금을 노선과 직각으로 세운다. + nx, ny = -dy / length, dx / length + cx, cy = paper(point) + tick = polyline_entity( + drawing_id, + [ + (cx - nx * _STATION_TICK_MM, cy - ny * _STATION_TICK_MM), + (cx + nx * _STATION_TICK_MM, cy + ny * _STATION_TICK_MM), + ], + STATION_LAYER_ID, + STATION_COLOR, + suffix=f":tick:{index}", + ) + if tick: + entities.append(tick) + entities.append( + _text_entity( + f"{drawing_id}:station:{index}", + station_plus_label(chainage, interval_m), + cx + nx * (_STATION_TICK_MM + 1.5), + cy + ny * (_STATION_TICK_MM + 1.5), + STATION_LAYER_ID, + _STATION_FONT_SIZE, + STATION_COLOR, + ) + ) + return entities + + +def _structure_entities( + drawing_id: str, + structures: list[dict[str, Any]], + paper: Any, + box: tuple[float, float, float, float], +) -> list[dict[str, Any]]: + """구조물 위치를 마름모 기호 + 이름으로 찍는다. 이 장의 범위 밖은 건너뛴다.""" + entities: list[dict[str, Any]] = [] + min_x, min_y, max_x, max_y = box + for index, structure in enumerate(structures): + x, y = structure.get("x"), structure.get("y") + if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): + continue + if not (min_x <= x <= max_x and min_y <= y <= max_y): + continue + cx, cy = paper((float(x), float(y))) + size = _STRUCTURE_SIZE_MM + marker = polyline_entity( + drawing_id, + [ + (cx, cy + size), + (cx + size, cy), + (cx, cy - size), + (cx - size, cy), + (cx, cy + size), + ], + STRUCTURE_LAYER_ID, + STRUCTURE_COLOR, + suffix=f":structure:{index}", + ) + if marker: + entities.append(marker) + entities.append( + _text_entity( + f"{drawing_id}:structure:label:{index}", + _structure_label(structure), + cx + size + 1.0, + cy, + STRUCTURE_LAYER_ID, + _STRUCTURE_FONT_SIZE, + STRUCTURE_COLOR, + ) + ) + return entities + + +def build_plan_drawing( + kind: str, + drawing_id: str, + label: str, + route_xy: list[tuple[float, float]], + stations: list[tuple[float, float, float]], + contours: list[list[tuple[float, float]]], + streams: list[list[tuple[float, float]]], + structures: list[dict[str, Any]], + interval_m: float = 20.0, +) -> dict[str, Any]: + """계획평면도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. + + `kind` 가 세 장 중 무엇을 그릴지 정한다(`PLAN_KINDS`). 배경은 세 장이 같다. + """ + with_route, with_station, with_structure = next( + ((r, s, t) for name, _label, r, s, t in PLAN_KINDS if name == kind), + (True, False, False), + ) + # 도곽 배치는 세 장이 같아야 한다 — 노선을 그리지 않는 지형도도 노선을 범위에 넣는다. + everything = [ + *route_xy, + *(point for line in contours for point in line), + *(point for line in streams for point in line), + ] + if not everything: + raise FileNotFoundError( + "계획평면도에 그릴 좌표가 없습니다. B04 전처리에서 수치지형도 도엽을 먼저 받으세요." + ) + min_x = min(x for x, _y in everything) + min_y = min(y for _x, y in everything) + max_x = max(x for x, _y in everything) + max_y = max(y for _x, y in everything) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) + + entities: list[dict[str, Any]] = [] + for index, line in enumerate(contours): + contour = polyline_entity( + drawing_id, + [paper(point) for point in line], + CONTOUR_LAYER_ID, + CONTOUR_COLOR, + suffix=f":contour:{index}", + ) + if contour: + entities.append(contour) + for index, line in enumerate(streams): + stream = polyline_entity( + drawing_id, + [paper(point) for point in line], + STREAM_LAYER_ID, + STREAM_COLOR, + suffix=f":stream:{index}", + ) + if stream: + entities.append(stream) + + map_bbox = entities_bbox(entities) + + # 노선·측점·구조물은 배경 위에 얹는다 — 아래에 깔리면 등고선에 묻힌다. + if with_route: + route = polyline_entity( + drawing_id, + [paper(point) for point in route_xy], + ROUTE_LAYER_ID, + ROUTE_COLOR, + width=_ROUTE_WIDTH, + ) + if route: + entities.append(route) + if with_station and stations: + entities.extend(_station_entities(drawing_id, stations, interval_m, paper)) + if with_structure: + entities.extend( + _structure_entities(drawing_id, structures, paper, (min_x, min_y, max_x, max_y)) + ) + + # 방위표는 지형 오른쪽 칸 맨 위에 둔다(유역도와 같은 자리). + if map_bbox: + entities.extend( + compass_entities( + drawing_id, + ( + map_bbox[2] + _COMPASS_MARGIN + _COMPASS_SIZE / 2.0, + map_bbox[3] - _COMPASS_SIZE / 2.0, + ), + _COMPASS_SIZE, + ) + ) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_PLAN:,}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(CONTOUR_LAYER_ID, "등고선", locked=True), + _layer(STREAM_LAYER_ID, "계류", locked=True), + _layer(ROUTE_LAYER_ID, "계획노선"), + _layer(STATION_LAYER_ID, "측점"), + _layer(STRUCTURE_LAYER_ID, "구조물"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 43a1e52d..7a67125f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -41,7 +41,8 @@ from config.config_system import DRAWING_SCALE_CROSS CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?") # 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다. -_BLOCK_GAP_MM = 8.0 +# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거). +_BLOCK_GAP_MM = 0.0 _BORDER_PAD_MM = 4.0 @@ -84,72 +85,85 @@ def section_block_size( return (width, top + below) -def _pack( - blocks: list[tuple[int, float, float]], start: int, rows: int -) -> tuple[int, list[float], list[float]]: - """blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다. - - 열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을 - 전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정). - """ +def _grid_for(group: list[tuple[int, float, float]]) -> tuple[float, float, int, int]: + """한 장에 담을 블록 묶음의 (칸폭, 칸높이, 열수, 행수) — 칸은 그 장 최대 블록 기준.""" usable_w, usable_h = usable_area() - col_widths: list[float] = [] - row_heights: list[float] = [0.0] * rows - count = 0 - for index, (_chainage, width, height) in enumerate(blocks[start:]): - column, row = divmod(index, rows) - current = col_widths[column] if column < len(col_widths) else 0.0 - new_col = max(current, width + _BLOCK_GAP_MM) - new_row = max(row_heights[row], height + _BLOCK_GAP_MM) - if sum(col_widths[:column]) + new_col > usable_w: - break - if sum(row_heights) - row_heights[row] + new_row > usable_h: - break - if column < len(col_widths): - col_widths[column] = new_col - else: - col_widths.append(new_col) - row_heights[row] = new_row - count = index + 1 - return count, col_widths, row_heights + cell_w = max(width for _c, width, _h in group) + _BLOCK_GAP_MM + cell_h = max(height for _c, _w, height in group) + _BLOCK_GAP_MM + return cell_w, cell_h, int(usable_w // cell_w), int(usable_h // cell_h) -def _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]: +def _max_take(blocks: list[tuple[int, float, float]], start: int) -> int: + """blocks[start:] 를 한 장에 담을 수 있는 최대 개수(칸 통일 기준).""" + limit = 0 + for take in range(1, len(blocks) - start + 1): + _cw, _ch, columns, rows = _grid_for(blocks[start : start + take]) + if columns * rows < take: + break + limit = take + return limit + + +def _sheet_breaks(blocks: list[tuple[int, float, float]]) -> list[int]: + """장 경계를 **전체 최소 장수**가 되도록 고른다 (측점 순서는 유지). + + 앞에서부터 최대한 채우면 바로 뒤에 큰 단면이 오는 순간 칸이 그 단면 크기로 + 튀어 그 장이 통째로 비었다(2026-09-04 실측: 6칸짜리 장에 1개만 배치). 단면 + 크기는 측점마다 원지반 기울기로 달라지므로, 경계를 뒤에서부터 훑어 최소 장수 + 조합을 고른다 — 큰 단면은 자기 장에 몰리고 비슷한 크기끼리 한 장에 모인다. + 같은 장수면 **앞 장을 더 많이 채우는 쪽**을 고른다(뒷장에 여백을 몰아 준다). + """ + total = len(blocks) + best_sheets = [0] * (total + 1) + best_take = [0] * (total + 1) + for start in range(total - 1, -1, -1): + limit = max(_max_take(blocks, start), 1) + choice = (total + 1, 0) + for take in range(1, limit + 1): + candidate = (best_sheets[start + take] + 1, -take) + if candidate < choice: + choice = candidate + best_sheets[start], best_take[start] = choice[0], -choice[1] + breaks: list[int] = [] + start = 0 + while start < total: + breaks.append(best_take[start]) + start += best_take[start] + return breaks + + +def _slots(cell_w: float, cell_h: float, rows: int, count: int) -> list[list[float]]: """칸의 (가로 중심, 아래 변) — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열. - 세로는 중심이 아니라 **아래 변**을 준다. 수량표 높이는 모든 블록이 같으므로 - 아래를 맞추면 같은 행의 표가 한 줄로 선다(2026-08-30 사용자: 표는 행·열을 맞춘다). + 칸이 모두 같은 크기이므로 격자 좌표만 계산하면 된다(2026-08-29 사용자: 채우는 + 순서는 좌하단부터 열 우선). """ usable_w, usable_h = usable_area() - rows = len(row_heights) slots: list[list[float]] = [] for index in range(count): column, row = divmod(index, rows) - x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0 - y = -usable_h / 2.0 + sum(row_heights[:row]) - slots.append([x, y]) + slots.append( + [ + -usable_w / 2.0 + column * cell_w + cell_w / 2.0, + -usable_h / 2.0 + row * cell_h, + ] + ) return slots def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]: """(측점, 폭, 높이) 목록을 A1 장으로 나눈다. - 블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에 - 담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다). + 한 장 안의 칸은 모두 같은 크기(그 장 최대 블록 기준)이고 빈 곳은 여백으로 둔다. + 작성 척도는 1/100 고정 — 안 들어가면 장을 나눌 뿐 줄이지 않는다(지식DB + 「설계제원_총괄」 측량·도면 기준). 장에 담기는 측점 수는 세트마다 다르다 + (2026-09-04 사용자 확정). """ sheets: list[dict[str, Any]] = [] start = 0 - while start < len(blocks): - best: tuple[int, list[float], list[float]] = (0, [], []) - for rows in range(1, len(blocks) - start + 1): - packed = _pack(blocks, start, rows) - if packed[0] > best[0]: - best = packed - count, col_widths, row_heights = best - if count == 0: # 한 칸도 못 담을 만큼 큰 블록 — 그래도 한 장에 하나는 놓는다. - count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]] + for count in _sheet_breaks(blocks): group = blocks[start : start + count] - number = len(sheets) + 1 + cell_w, cell_h, _columns, rows = _grid_for(group) chainages = [chainage for chainage, _w, _h in group] sheets.append( { @@ -157,10 +171,12 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, # 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을 # 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적). "id": f"cross_s{chainages[0]:05d}m", - "number": number, + "number": len(sheets) + 1, "chainages": chainages, - "rows": len(row_heights), - "slots": _slots(col_widths, row_heights, count), + "rows": max(rows, 1), + "cell_width": cell_w, + "cell_height": cell_h, + "slots": _slots(cell_w, cell_h, max(rows, 1), count), } ) start += count @@ -172,6 +188,8 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> entities: list[dict[str, Any]] = [] placements: list[dict[str, Any]] = [] slots = sheet.get("slots") or [] + cell_w = float(sheet.get("cell_width") or 0.0) + cell_h = float(sheet.get("cell_height") or 0.0) for index, section in enumerate(sections): if index >= len(slots): @@ -197,6 +215,16 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> center_x - (min_x + max_x) / 2.0, bottom_y + _BLOCK_GAP_MM / 2.0 - min_y, ) + # 3) 테두리는 칸 크기로 통일한다 — 단면 크기와 무관하게 한 장 안에서 같은 크기. + cell_frame = None + if cell_w > 0.0 and cell_h > 0.0: + half = (cell_w - _BLOCK_GAP_MM) / 2.0 + cell_frame = ( + center_x - half, + bottom_y + _BLOCK_GAP_MM / 2.0, + center_x + half, + bottom_y + cell_h - _BLOCK_GAP_MM / 2.0, + ) placed = build_cross_drawing( section["source"], seed_id, @@ -205,6 +233,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> section.get("quantity_table"), section.get("title", ""), origin=origin, + cell_frame=cell_frame, ) entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py new file mode 100644 index 00000000..ce03210c --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py @@ -0,0 +1,581 @@ +"""B07 표준 횡단면도 CAD 조립 — 변수 모식도에 치수를 넣고, 측구 확대도·암반선 2단을 함께 낸다. + +사용자 지시(2026-09-04) — 「표준 횡단면도 좌상단에 기본값으로 B06 좌측 패널의 변수 위치 +안내와 비슷한 그림을 넣고, 변수 이름 자리에 도면처럼 치수를 적을 것. 측구는 작으니 부분 +확대도로. 발파·암반이면 암반선과 각도를 넣어 절토측이 2단(토사 각도 + 암반 각도)으로 +표현될 것」. + +배치는 B06 좌측 패널 모식도(`B06_Section_UI_Standard_Diagram.ts`)와 같다 — 좌가 절토·측구, +우가 성토, 가운데가 계획고. 다른 점은 **실치수**라는 것이다. 모식도는 위치 안내라 비율이 +없지만 도면은 축척(본 그림 1/50, 측구 확대도 1/10)대로 그리고 치수선을 붙인다. + +값은 B06 「표준 횡단면 설정」(`standard_cross_section`)을 그대로 읽는다 — 여기서 기하를 +다시 정하지 않는다. 저장값이 없으면 config 기본값을 쓴다. + +좌표 규약: 종이 mm = 실거리 m x MM. 원점은 계획고(노면 중심). +""" + +import math +from typing import Any + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + DRAWING_FORMAT, + FRAME_LAYER_ID, + TABLE_LABEL_COLOR, + _layer, + _text_entity, + polyline_entity, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + frame_entities, + scale_fields, +) +from config.config_system import ( + DRAWING_SCALE_CROSS_STANDARD, + DRAWING_SCALE_DITCH_DETAIL, + STANDARD_CROSS_SECTION, +) + +STANDARD_KIND = "cross_standard" +STANDARD_LABEL = "표준 횡단면도" + +# 도면 좌표 = 종이 mm. 본 그림 1/50 -> 1 m = 20 mm, 측구 확대도 1/10 -> 1 m = 100 mm. +MM = 1000.0 / DRAWING_SCALE_CROSS_STANDARD +DETAIL_MM = 1000.0 / DRAWING_SCALE_DITCH_DETAIL + +SECTION_LAYER_ID = "b07-std-section" +ROCK_LAYER_ID = "b07-std-rock" +DIM_LAYER_ID = "b07-std-dim" +DETAIL_LAYER_ID = "b07-std-detail" +NOTE_LAYER_ID = "b07-std-note" +TITLE_LAYER_ID = "b07-std-title" + +SECTION_COLOR = "#111111" +ROCK_COLOR = "#a63d3d" +DIM_COLOR = "#2f6fb0" +DETAIL_COLOR = "#111111" +NOTE_COLOR = "#333333" + +_LINE_WIDTH = 2 +_TITLE_FONT_SIZE = 7.0 +_LABEL_FONT_SIZE = 3.0 +_DIM_FONT_SIZE = 2.6 +_NOTE_FONT_SIZE = 2.8 + +# 그림에 세울 절·성토 높이(m) — 표준도는 실제 지형이 없으므로 대표 높이로 그린다. +_CUT_HEIGHT_M = 3.0 +_FILL_HEIGHT_M = 3.0 +# 암반 구간: 절토 밑에서 이만큼이 암반이고 그 위가 토사다(2단 절토). +_ROCK_HEIGHT_M = 1.5 + +_DIM_TICK_MM = 1.6 # 치수선 끝 눈금 반길이 +_DIM_OFFSET_MM = 8.0 # 치수선을 그림에서 띄우는 거리 +_DIM_GAP_MM = 6.0 # 치수선 단 사이 + + +def _ground(kind: str, standard: dict[str, Any] | None) -> dict[str, Any]: + """표준 횡단면 설정에서 한 지반유형 값을 꺼낸다. 없으면 config 기본값.""" + stored = (standard or {}).get(kind) + if isinstance(stored, dict) and stored: + merged = dict(STANDARD_CROSS_SECTION.get(kind) or {}) + merged.update(stored) + return merged + return dict(STANDARD_CROSS_SECTION.get(kind) or {}) + + +def _number(value: Any, fallback: float) -> float: + return float(value) if isinstance(value, (int, float)) else fallback + + +def _dim_entities( + drawing_id: str, + tag: str, + start: tuple[float, float], + end: tuple[float, float], + label: str, + layer_id: str = DIM_LAYER_ID, +) -> list[dict[str, Any]]: + """치수선 한 벌(치수선 + 양끝 눈금 + 치수값). 좌표는 종이 mm.""" + entities: list[dict[str, Any]] = [] + line = polyline_entity(drawing_id, [start, end], layer_id, DIM_COLOR, suffix=f":dim:{tag}") + if line: + entities.append(line) + dx, dy = end[0] - start[0], end[1] - start[1] + length = math.hypot(dx, dy) or 1.0 + nx, ny = -dy / length, dx / length + for index, point in enumerate((start, end)): + tick = polyline_entity( + drawing_id, + [ + (point[0] - nx * _DIM_TICK_MM, point[1] - ny * _DIM_TICK_MM), + (point[0] + nx * _DIM_TICK_MM, point[1] + ny * _DIM_TICK_MM), + ], + layer_id, + DIM_COLOR, + suffix=f":dim:{tag}:tick:{index}", + ) + if tick: + entities.append(tick) + entities.append( + _text_entity( + f"{drawing_id}:dim:{tag}:text", + label, + (start[0] + end[0]) / 2.0 + nx * 2.2, + (start[1] + end[1]) / 2.0 + ny * 2.2, + layer_id, + _DIM_FONT_SIZE, + DIM_COLOR, + ) + ) + return entities + + +def _section_geometry(values: dict[str, Any]) -> dict[str, Any]: + """표준 단면의 실좌표(m) 꼭짓점. 좌가 절토·측구, 우가 성토(B06 모식도와 같은 배치).""" + road = _number(values.get("road_width_m"), 3.0) + shoulder_left = _number(values.get("shoulder_left_m"), 0.5) + shoulder_right = _number(values.get("shoulder_right_m"), 0.5) + ditch = values.get("ditch") if isinstance(values.get("ditch"), dict) else {} + top_width = _number(ditch.get("top_width_m"), 0.9) + bottom_width = _number(ditch.get("bottom_width_m"), 0.3) + depth = _number(ditch.get("depth_m"), 0.3) + slope = values.get("cross_slope_pct") if isinstance(values.get("cross_slope_pct"), dict) else {} + cross_pct = _number(slope.get("max"), _number(slope.get("min"), 3.0)) + cut_ratio = _number(values.get("cut_slope_ratio"), 1.0) + fill_ratio = _number(values.get("fill_slope_ratio"), 1.2) + + road_left = -(road / 2.0 + shoulder_left) + road_right = road / 2.0 + shoulder_right + # 횡단경사는 측구(좌) 쪽으로 내려간다 — 노면 좌끝이 계획고보다 낮다. + drop = abs(road_left) * cross_pct / 100.0 + surface = [(road_right, 0.0), (road_left, -drop)] + + ditch_top_left = road_left - top_width + ditch_bottom_y = -drop - depth + inset = (top_width - bottom_width) / 2.0 + ditch_line = [ + (road_left, -drop), + (road_left - inset, ditch_bottom_y), + (ditch_top_left + inset, ditch_bottom_y), + (ditch_top_left, -drop), + ] + cut_top = (ditch_top_left - _CUT_HEIGHT_M * cut_ratio, -drop + _CUT_HEIGHT_M) + fill_toe = (road_right + _FILL_HEIGHT_M * fill_ratio, -_FILL_HEIGHT_M) + return { + "road": road, + "shoulder_left": shoulder_left, + "shoulder_right": shoulder_right, + "top_width": top_width, + "bottom_width": bottom_width, + "depth": depth, + "cross_pct": cross_pct, + "cut_ratio": cut_ratio, + "fill_ratio": fill_ratio, + "road_left": road_left, + "road_right": road_right, + "drop": drop, + "surface": surface, + "ditch_line": ditch_line, + "ditch_top_left": ditch_top_left, + "cut_start": (ditch_top_left, -drop), + "cut_top": cut_top, + "fill_toe": fill_toe, + } + + +def _rock_entities( + drawing_id: str, geometry: dict[str, Any], rock_ratio: float, paper: Any +) -> list[dict[str, Any]]: + """암반선과 2단 절토(아래=암반각, 위=토사각)를 절토측에 덧그린다.""" + entities: list[dict[str, Any]] = [] + start_x, start_y = geometry["cut_start"] + soil_ratio = geometry["cut_ratio"] + # 아래 단: 암반각으로 _ROCK_HEIGHT_M 만큼 올라간다. + bench = (start_x - _ROCK_HEIGHT_M * rock_ratio, start_y + _ROCK_HEIGHT_M) + # 위 단: 그 위는 토사각. + upper = ( + bench[0] - (_CUT_HEIGHT_M - _ROCK_HEIGHT_M) * soil_ratio, + bench[1] + (_CUT_HEIGHT_M - _ROCK_HEIGHT_M), + ) + two_stage = polyline_entity( + drawing_id, + [paper((start_x, start_y)), paper(bench), paper(upper)], + ROCK_LAYER_ID, + ROCK_COLOR, + suffix=":rock:cut", + width=_LINE_WIDTH, + ) + if two_stage: + entities.append(two_stage) + # 암반선 — 2단이 갈리는 높이의 수평 파선. + boundary = polyline_entity( + drawing_id, + [paper((bench[0] - 1.5, bench[1])), paper((geometry["road_right"], bench[1]))], + ROCK_LAYER_ID, + ROCK_COLOR, + suffix=":rock:boundary", + dash=[6, 4], + ) + if boundary: + entities.append(boundary) + label_x, label_y = paper((bench[0] - 1.6, bench[1])) + entities.append( + _text_entity( + f"{drawing_id}:rock:boundary:text", + "암반선", + label_x, + label_y + 2.5, + ROCK_LAYER_ID, + _LABEL_FONT_SIZE, + ROCK_COLOR, + align="right", + ) + ) + mid_lower = paper(((start_x + bench[0]) / 2.0, (start_y + bench[1]) / 2.0)) + mid_upper = paper(((bench[0] + upper[0]) / 2.0, (bench[1] + upper[1]) / 2.0)) + entities.append( + _text_entity( + f"{drawing_id}:rock:lower", + f"암반 1:{rock_ratio:g}", + mid_lower[0] - 6.0, + mid_lower[1], + ROCK_LAYER_ID, + _DIM_FONT_SIZE, + ROCK_COLOR, + align="right", + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:rock:upper", + f"토사 1:{soil_ratio:g}", + mid_upper[0] - 6.0, + mid_upper[1], + ROCK_LAYER_ID, + _DIM_FONT_SIZE, + ROCK_COLOR, + align="right", + ) + ) + return entities + + +def _ditch_detail_entities( + drawing_id: str, geometry: dict[str, Any], origin: tuple[float, float] +) -> list[dict[str, Any]]: + """측구 부분 확대도(1/10) — 작아서 본 그림에서는 치수를 읽을 수 없다.""" + entities: list[dict[str, Any]] = [] + top_width = geometry["top_width"] + bottom_width = geometry["bottom_width"] + depth = geometry["depth"] + inset = (top_width - bottom_width) / 2.0 + ox, oy = origin + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return (ox + point[0] * DETAIL_MM, oy + point[1] * DETAIL_MM) + + shape = [ + (0.0, 0.0), + (inset, -depth), + (inset + bottom_width, -depth), + (top_width, 0.0), + ] + outline = polyline_entity( + drawing_id, + [paper(point) for point in shape], + DETAIL_LAYER_ID, + DETAIL_COLOR, + suffix=":detail:ditch", + width=_LINE_WIDTH, + ) + if outline: + entities.append(outline) + entities.extend( + _dim_entities( + drawing_id, + "detail-top", + paper((0.0, 0.0 + 0.06)), + paper((top_width, 0.0 + 0.06)), + f"{top_width * 1000:.0f}", + DETAIL_LAYER_ID, + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "detail-bottom", + paper((inset, -depth - 0.06)), + paper((inset + bottom_width, -depth - 0.06)), + f"{bottom_width * 1000:.0f}", + DETAIL_LAYER_ID, + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "detail-depth", + paper((top_width + 0.08, 0.0)), + paper((top_width + 0.08, -depth)), + f"{depth * 1000:.0f}", + DETAIL_LAYER_ID, + ) + ) + title = paper((top_width / 2.0, 0.3)) + entities.append( + _text_entity( + f"{drawing_id}:detail:title", + f"측구 상세도 (S = 1/{DRAWING_SCALE_DITCH_DETAIL})", + title[0], + title[1], + DETAIL_LAYER_ID, + _LABEL_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + return entities + + +def build_standard_cross_drawing( + drawing_id: str, label: str, standard: dict[str, Any] | None = None +) -> dict[str, Any]: + """표준 횡단면도 한 장을 만든다 — 본 그림 + 치수 + 측구 확대도 + 암반 2단 + 주기.""" + soil = _ground("soil", standard) + rock = _ground("rock", standard) + paved = _ground("paved", standard) + geometry = _section_geometry(soil) + + def paper(point: tuple[float, float]) -> tuple[float, float]: + return (point[0] * MM, point[1] * MM) + + entities: list[dict[str, Any]] = [] + # 본 그림: 절토면 - 측구 - 노면 - 성토면을 한 줄로 잇는다. + outline = [ + geometry["cut_top"], + *geometry["ditch_line"][::-1], + *geometry["surface"][::-1], + geometry["fill_toe"], + ] + body = polyline_entity( + drawing_id, + [paper(point) for point in outline], + SECTION_LAYER_ID, + SECTION_COLOR, + suffix=":section", + width=_LINE_WIDTH, + ) + if body: + entities.append(body) + # 중심선(계획고). + center = polyline_entity( + drawing_id, + [paper((0.0, 1.2)), paper((0.0, -1.2))], + SECTION_LAYER_ID, + SECTION_COLOR, + suffix=":center", + dash=[10, 3, 2, 3], + ) + if center: + entities.append(center) + entities.append( + _text_entity( + f"{drawing_id}:center:text", + "계획고", + *paper((0.0, 1.45)), + SECTION_LAYER_ID, + _LABEL_FONT_SIZE, + SECTION_COLOR, + ) + ) + + # 치수선 — 노면 아래 두 단(위: 노견·노폭·노견, 아래: 노면 전폭). + base_y = min(geometry["fill_toe"][1], -geometry["drop"] - geometry["depth"]) + dim_y = base_y * MM - _DIM_OFFSET_MM + half = geometry["road"] / 2.0 + entities.extend( + _dim_entities( + drawing_id, + "shoulder-left", + (geometry["road_left"] * MM, dim_y), + (-half * MM, dim_y), + f"{geometry['shoulder_left'] * 1000:.0f}", + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "road", + (-half * MM, dim_y), + (half * MM, dim_y), + f"{geometry['road'] * 1000:.0f}", + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "shoulder-right", + (half * MM, dim_y), + (geometry["road_right"] * MM, dim_y), + f"{geometry['shoulder_right'] * 1000:.0f}", + ) + ) + entities.extend( + _dim_entities( + drawing_id, + "ditch-top", + (geometry["ditch_top_left"] * MM, dim_y), + (geometry["road_left"] * MM, dim_y), + f"{geometry['top_width'] * 1000:.0f}", + ) + ) + roadbed_width_m = geometry["road"] + geometry["shoulder_left"] + geometry["shoulder_right"] + entities.extend( + _dim_entities( + drawing_id, + "roadbed", + (geometry["road_left"] * MM, dim_y - _DIM_GAP_MM), + (geometry["road_right"] * MM, dim_y - _DIM_GAP_MM), + f"{roadbed_width_m * 1000:.0f}", + ) + ) + + # 경사·횡단경사 표기. + cut_mid = paper( + ( + (geometry["cut_start"][0] + geometry["cut_top"][0]) / 2.0, + (geometry["cut_start"][1] + geometry["cut_top"][1]) / 2.0, + ) + ) + fill_mid = paper( + ( + (geometry["road_right"] + geometry["fill_toe"][0]) / 2.0, + (0.0 + geometry["fill_toe"][1]) / 2.0, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:cut:text", + f"절토 1:{geometry['cut_ratio']:g}", + cut_mid[0] - 4.0, + cut_mid[1] + 3.0, + SECTION_LAYER_ID, + _DIM_FONT_SIZE, + SECTION_COLOR, + align="right", + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:fill:text", + f"성토 1:{geometry['fill_ratio']:g}", + fill_mid[0] + 4.0, + fill_mid[1] + 3.0, + SECTION_LAYER_ID, + _DIM_FONT_SIZE, + SECTION_COLOR, + align="left", + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:cross-slope:text", + f"횡단경사 {geometry['cross_pct']:g}%", + *paper((geometry["road_left"] / 2.0, 0.6)), + SECTION_LAYER_ID, + _DIM_FONT_SIZE, + SECTION_COLOR, + ) + ) + + # 암반 구간 2단 절토. + entities.extend( + _rock_entities(drawing_id, geometry, _number(rock.get("cut_slope_ratio"), 0.4), paper) + ) + + body_bbox = entities_bbox(entities) + right = body_bbox[2] if body_bbox else 0.0 + top = body_bbox[3] if body_bbox else 0.0 + + # 측구 부분 확대도 — 본 그림 오른쪽 위. + entities.extend(_ditch_detail_entities(drawing_id, geometry, (right + 28.0, top - 30.0))) + + # 주기: 구간별로 달라지는 값만 적는다(기본은 토사). + notes = [ + "※ 본 그림은 토사 구간 기준임.", + f"※ 암 구간 — 절토 1:{_number(rock.get('cut_slope_ratio'), 0.4):g}, " + f"L형 측구 {_number((rock.get('ditch_l_type') or {}).get('width_m'), 0.5) * 1000:.0f}" + f"×{_number((rock.get('ditch_l_type') or {}).get('depth_m'), 0.1) * 1000:.0f}mm " + "(횡단면도에서 일반·L형 중 선택).", + f"※ 포장 구간 — 절·성토 경사는 토사와 같고 횡단경사만 " + f"{_number((paved.get('cross_slope_pct') or {}).get('min'), 1.5):g}~" + f"{_number((paved.get('cross_slope_pct') or {}).get('max'), 2.0):g}% 임.", + "※ 치수 단위 mm.", + ] + note_bbox = entities_bbox(entities) + note_x = note_bbox[0] if note_bbox else 0.0 + note_y = (note_bbox[1] if note_bbox else 0.0) - 12.0 + for index, note in enumerate(notes): + entities.append( + _text_entity( + f"{drawing_id}:note:{index}", + note, + note_x, + note_y - index * 5.0, + NOTE_LAYER_ID, + _NOTE_FONT_SIZE, + NOTE_COLOR, + align="left", + ) + ) + + bbox = entities_bbox(entities) + if bbox: + min_bx, _min_by, max_bx, max_by = bbox + entities.append( + _text_entity( + f"{drawing_id}:title", + label, + (min_bx + max_bx) / 2.0, + max_by + 12.0, + TITLE_LAYER_ID, + _TITLE_FONT_SIZE, + TABLE_LABEL_COLOR, + ) + ) + entities.append( + _text_entity( + f"{drawing_id}:scale", + f"S = 1/{DRAWING_SCALE_CROSS_STANDARD}", + max_bx, + max_by + 5.0, + TITLE_LAYER_ID, + _DIM_FONT_SIZE, + TABLE_LABEL_COLOR, + align="right", + ) + ) + entities.extend( + frame_entities( + drawing_id, + entities_bbox(entities) or bbox, + fit=False, + fields={ + "도면명": label, + **scale_fields(("", DRAWING_SCALE_CROSS_STANDARD)), + }, + ) + ) + + return { + "format": DRAWING_FORMAT, + "entities": entities, + "layers": [ + _layer(SECTION_LAYER_ID, "표준 단면"), + _layer(ROCK_LAYER_ID, "암반선·2단 절토"), + _layer(DIM_LAYER_ID, "치수"), + _layer(DETAIL_LAYER_ID, "측구 상세도"), + _layer(NOTE_LAYER_ID, "주기"), + _layer(TITLE_LAYER_ID, "표제"), + _layer(FRAME_LAYER_ID, "도각", locked=True), + ], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py new file mode 100644 index 00000000..b99c8c33 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py @@ -0,0 +1,202 @@ +"""SVG 서명·로고를 CAD 폴리선으로 옮긴다 (2026-09-03 사용자 결정: 벡터로 변환). + +표제란 서명은 종전에 `ImageEntity`(data URL)로 들어갔다. 화면·PNG 는 문제없지만 DXF 로 +나가면 래스터 그림이 되고 확대하면 깨진다. **SVG 로 올라온 자산만** 벡터로 바꾼다 — +PNG·JPG 는 그릴 선이 없으므로 종전대로 그림으로 둔다. + +지원 도형: `path`(M·L·H·V·C·Q·S·T·A 제외·Z, 대소문자 = 절대·상대) · `polyline` · +`polygon` · `line`. 곡선은 고정 분할로 펴서 폴리선 하나로 만든다 — CAD 쪽에 곡선 +엔티티가 없고, 서명은 짧은 획이라 16분할이면 눈으로 구분되지 않는다. +""" + +from __future__ import annotations + +import logging +import re +from xml.etree import ElementTree + +logger = logging.getLogger(__name__) + +# 베지에 한 구간을 몇 조각으로 펼지 — 서명 획 길이(수 mm)에서 충분한 값. +_CURVE_STEPS = 16 +_NUMBER = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") +_COMMAND = re.compile(r"([MmLlHhVvCcQqSsTtZz])") +_SVG_NS = "{http://www.w3.org/2000/svg}" + +Point = tuple[float, float] + + +def _numbers(text: str) -> list[float]: + return [float(value) for value in _NUMBER.findall(text or "")] + + +def _bezier(points: list[Point], steps: int = _CURVE_STEPS) -> list[Point]: + """드 카스텔조 분할 — 3차·2차 모두 같은 식으로 편다(제어점 개수만 다르다).""" + flattened: list[Point] = [] + for index in range(1, steps + 1): + t = index / steps + current = points + while len(current) > 1: + current = [ + (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + for a, b in zip(current, current[1:]) + ] + flattened.append(current[0]) + return flattened + + +def _path_polylines(data: str) -> list[list[Point]]: + """`d` 속성 → 폴리선 목록. 미지원 명령(A 등)을 만나면 그 자리에서 획을 끊는다.""" + runs: list[list[Point]] = [] + current: list[Point] = [] + cursor: Point = (0.0, 0.0) + start: Point = (0.0, 0.0) + previous_control: Point | None = None + tokens = [token for token in _COMMAND.split(data or "") if token.strip()] + index = 0 + while index < len(tokens): + command = tokens[index] + args = _numbers(tokens[index + 1]) if index + 1 < len(tokens) else [] + index += 2 if index + 1 < len(tokens) else 1 + relative = command.islower() + upper = command.upper() + + def absolute(x: float, y: float) -> Point: + return (cursor[0] + x, cursor[1] + y) if relative else (x, y) + + if upper == "M": + for pair in range(0, len(args) - 1, 2): + point = absolute(args[pair], args[pair + 1]) + if pair == 0: + if len(current) >= 2: + runs.append(current) + current = [point] + start = point + else: + current.append(point) + cursor = point + previous_control = None + elif upper in {"L", "T"}: + for pair in range(0, len(args) - 1, 2): + point = absolute(args[pair], args[pair + 1]) + current.append(point) + cursor = point + previous_control = None + elif upper == "H": + for value in args: + point = (cursor[0] + value, cursor[1]) if relative else (value, cursor[1]) + current.append(point) + cursor = point + previous_control = None + elif upper == "V": + for value in args: + point = (cursor[0], cursor[1] + value) if relative else (cursor[0], value) + current.append(point) + cursor = point + previous_control = None + elif upper in {"C", "S", "Q"}: + stride = {"C": 6, "S": 4, "Q": 4}[upper] + for offset in range(0, len(args) - stride + 1, stride): + chunk = args[offset : offset + stride] + if upper == "C": + control1 = absolute(chunk[0], chunk[1]) + control2 = absolute(chunk[2], chunk[3]) + end = absolute(chunk[4], chunk[5]) + elif upper == "S": + mirrored = previous_control or cursor + control1 = (2 * cursor[0] - mirrored[0], 2 * cursor[1] - mirrored[1]) + control2 = absolute(chunk[0], chunk[1]) + end = absolute(chunk[2], chunk[3]) + else: + control1 = absolute(chunk[0], chunk[1]) + control2 = control1 + end = absolute(chunk[2], chunk[3]) + current.extend(_bezier([cursor, control1, control2, end])) + previous_control = control2 + cursor = end + elif upper == "Z": + if current: + current.append(start) + runs.append(current) + current = [] + cursor = start + previous_control = None + else: + # 지원하지 않는 명령(원호 A 등) — 여기서 획을 끊고 다음 M 을 기다린다. + if len(current) >= 2: + runs.append(current) + current = [] + previous_control = None + if len(current) >= 2: + runs.append(current) + return runs + + +def svg_polylines(svg_text: str) -> tuple[tuple[float, float, float, float], list[list[Point]]]: + """SVG 원문 → (viewBox, 폴리선 목록). 읽지 못하면 빈 목록. + + viewBox 가 없으면 도형 전체 bbox 를 대신 쓴다 — 자리 맞추기에만 쓰는 값이다. + """ + try: + root = ElementTree.fromstring(svg_text) + except ElementTree.ParseError as exc: + logger.warning("서명 SVG 를 읽지 못했습니다 — %s", exc) + return ((0.0, 0.0, 1.0, 1.0), []) + + runs: list[list[Point]] = [] + for element in root.iter(): + tag = element.tag.replace(_SVG_NS, "") + if tag == "path": + runs.extend(_path_polylines(element.get("d", ""))) + elif tag in {"polyline", "polygon"}: + values = _numbers(element.get("points", "")) + points = [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)] + if tag == "polygon" and len(points) > 2: + points.append(points[0]) + if len(points) >= 2: + runs.append(points) + elif tag == "line": + runs.append( + [ + (float(element.get("x1", 0)), float(element.get("y1", 0))), + (float(element.get("x2", 0)), float(element.get("y2", 0))), + ] + ) + + box = _numbers(root.get("viewBox", "")) + if len(box) == 4 and box[2] > 0 and box[3] > 0: + return ((box[0], box[1], box[2], box[3]), runs) + xs = [x for run in runs for x, _y in run] + ys = [y for run in runs for _x, y in run] + if not xs or not ys: + return ((0.0, 0.0, 1.0, 1.0), runs) + width = max(max(xs) - min(xs), 1e-6) + height = max(max(ys) - min(ys), 1e-6) + return ((min(xs), min(ys), width, height), runs) + + +def fit_polylines( + runs: list[list[Point]], + view_box: tuple[float, float, float, float], + rect: tuple[float, float, float, float], +) -> list[list[Point]]: + """폴리선을 도면 사각형(x0, y0, x1, y1) 안에 **비율 유지**로 앉힌다. + + SVG 는 y 가 아래로 자라고 도면은 위로 자라므로 세로를 뒤집는다. + """ + vx, vy, vw, vh = view_box + x0, y0, x1, y1 = rect + box_w = abs(x1 - x0) + box_h = abs(y1 - y0) + if vw <= 0 or vh <= 0 or box_w <= 0 or box_h <= 0: + return [] + scale = min(box_w / vw, box_h / vh) + pad_x = (box_w - vw * scale) / 2.0 + pad_y = (box_h - vh * scale) / 2.0 + left = min(x0, x1) + bottom = min(y0, y1) + top = bottom + box_h + return [ + [(left + pad_x + (x - vx) * scale, top - pad_y - (y - vy) * scale) for x, y in run] + for run in runs + ] diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index 20844ec4..d0f1fce7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -223,12 +223,28 @@ def _cross_table_entities( ] -def _table_values_from_cells(entities: list[Any]) -> dict[str, float | None]: - """표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다.""" +def _table_values_from_cells( + entities: list[Any], drawing_id: str | None = None +) -> dict[str, float | None]: + """표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다. + + **한 도면에 표가 여럿이면 그 측점 것만 읽는다**(장은 측점 4~6개를 담는다). + 표 엔티티 id 는 `uuid5(f"{도면id}:qtable")` 이라 측점을 되짚을 수 있다. 이 걸름이 + 없던 동안 장 확정이 **모든 측점에 마지막 표 값을 넣었다**(2026-09-03 실측: 4개 측점이 + 전부 지반고 837.21 — 도면에는 840.87·840.33·836.45·837.21 로 제대로 그려져 있었다). + id 로 못 찾으면(옛 도면) 종전처럼 전부 훑는다. + """ + wanted = str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable")) if drawing_id else None + if wanted is not None and not any( + isinstance(entity, dict) and entity.get("id") == wanted for entity in entities + ): + wanted = None table: dict[str, float | None] = {} for entity in entities: if not isinstance(entity, dict) or entity.get("type") != "Table": continue + if wanted is not None and entity.get("id") != wanted: + continue shape = entity.get("shapeData") rows = shape.get("cells") if isinstance(shape, dict) else None if not isinstance(rows, list): @@ -281,7 +297,7 @@ def extract_quantity_table( entities = drawing.get("entities") if not isinstance(entities, list): return None - table = _table_values_from_cells(entities) + table = _table_values_from_cells(entities, drawing_id) if not table: table = _table_values_from_texts(drawing_id, entities) if not table: 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 eb8dec65..e0e20440 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -9,25 +9,32 @@ A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표 내부 작도 영역 (42, 47) ~ (812, 567). """ +import binascii import json import logging import re +from base64 import b64decode from contextvars import ContextVar from functools import lru_cache from pathlib import Path from typing import Any -from uuid import uuid5 +from uuid import NAMESPACE_URL, uuid5 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" logger = logging.getLogger(__name__) +# 벡터로 바꾼 서명 폴리선 id 를 결정적으로 만드는 이름공간 — 같은 도면을 두 번 뽑아도 +# 엔티티 id 가 같아야 CAD 가 같은 것으로 본다. +_SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector") + A1_TEMPLATE = "00_template_A1" COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표 # A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다. @@ -106,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}], } @@ -186,6 +198,12 @@ def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, if isinstance(p, dict): xs.append(float(p["x"])) ys.append(float(p["y"])) + # 꼭짓점 배열을 쓰는 엔티티(Image·Hatch)도 범위에 넣는다 — 넣지 않으면 라이다 + # 음영기복 그림이 도곽 계산에서 통째로 빠진다(2026-09-04). + for vertex in shape.get("points") or []: + if isinstance(vertex, dict) and "x" in vertex and "y" in vertex: + xs.append(float(vertex["x"])) + ys.append(float(vertex["y"])) center = shape.get("center") if isinstance(center, dict): r = float(shape.get("radius", 0.0)) @@ -319,11 +337,74 @@ def _fill_placeholders( if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image: shape["imageData"] = substitute(image) # 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다. - return [ + kept = [ entity for entity in entities if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData") ] + return [replaced for entity in kept for replaced in _vectorize_svg_image(entity)] + + +def _vectorize_svg_image(entity: dict[str, Any]) -> list[dict[str, Any]]: + """SVG 로 올라온 서명·로고를 CAD 폴리선으로 바꾼다(2026-09-03 사용자 결정). + + 래스터(PNG·JPG)는 그릴 선이 없으므로 종전대로 `ImageEntity` 로 둔다. SVG 만 벡터로 + 바꿔 DXF 로 나가도 선으로 남고 확대해도 깨지지 않게 한다. 읽지 못하면 원래 그림을 + 그대로 둔다 — 서명이 통째로 사라지는 것보다 낫다. + """ + if entity.get("type") != "Image": + return [entity] + shape = entity.get("shapeData") or {} + data_url = shape.get("imageData") + if not isinstance(data_url, str) or not data_url.startswith("data:image/svg+xml;base64,"): + return [entity] + points = shape.get("points") or [] + if len(points) < 4: + return [entity] + try: + svg_text = b64decode(data_url.split(",", 1)[1]).decode("utf-8", errors="ignore") + except (ValueError, binascii.Error) as exc: + logger.warning("서명 SVG data URL 을 풀지 못했습니다 — %s", exc) + return [entity] + + view_box, runs = svg_polylines(svg_text) + if not runs: + return [entity] + xs = [float(point["x"]) for point in points] + ys = [float(point["y"]) for point in points] + fitted = fit_polylines(runs, view_box, (min(xs), min(ys), max(xs), max(ys))) + layer_id = entity.get("layerId") + color = entity.get("lineColor", "#000000") + vector: list[dict[str, Any]] = [] + for index, run in enumerate(fitted): + children = [ + { + "id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}:{step}")), + "type": "Line", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "startPoint": {"x": run[step][0], "y": run[step][1]}, + "endPoint": {"x": run[step + 1][0], "y": run[step + 1][1]}, + }, + } + for step in range(len(run) - 1) + ] + if not children: + continue + vector.append( + { + "id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}")), + "type": "PolyLine", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": None, + "children": children, + } + ) + return vector or [entity] def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]: diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 49cc9d7e..e81516c3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -17,6 +17,7 @@ from B06_Section.B06_Section_Repository import ( get_cross_section_design, get_cross_section_designs, get_longitudinal_section, + get_project_standard_cross_section, merge_cross_section_design_by_round, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( @@ -26,22 +27,26 @@ 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, ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( + CROSS_STANDARD_ID, + LANDUSE_ID, + LIDAR_ID, MASS_HAUL_ID, + PLAN_ID, WATERSHED_ID, _cross_sheet_plan, _drawing_list, _invalidate_drawing, _read_drawing, + _read_json, _recompute_confirmed_design, _store_confirmed_drawing, + landuse_source, + lidar_source, + plan_source, watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Schema import ( @@ -50,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 @@ -112,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`가 빈칸으로 지운다 — 도각 원본에 남의 값이 @@ -120,9 +122,15 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: 사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는 배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게. - 로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다 — - 안 골랐거나 자산이 지워졌으면 그림째 빠진다. 011 의 `companies.logo_path` · - `users.signature_path` 는 더 읽지 않는다. + **이름이 들어가는 자리는 서명도 받는다**(2026-09-02 사용자 확정) — 세 사람 모두 + 자기 계정의 `SIGNATURE` 자산을 따라가고, 안 올린 사람 자리는 그림째 빠진다. + 로고·서명은 회사 공유 자산(`company_assets`, 013)에서 오되 **원천이 다르다** + (2026-09-02 사용자 확정, B01 인계). 서명은 프로젝트가 아니라 **사람 계정**에 붙으므로 + 설계자(`designer_user_id`, 없으면 소유자) 계정의 `SIGNATURE` 자산을 따라간다. 로고는 + 프로젝트가 고른 것이 우선이고, 안 골랐으면 **회사 등록 단계에서 받은 회사 로고** + (`companies.logo_asset_id`, 014)로 떨어진다. 둘 다 없거나 자산이 지워졌으면 그림째 + 빠진다. `projects.signature_asset_id` 는 컬럼만 남기고 더 읽지 않는다(B01 이 항상 + `null` 로 저장). 011 의 `companies.logo_path` · `users.signature_path` 도 안 읽는다. 아직 못 채우는 자리와 이유: - 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸. @@ -137,7 +145,7 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount, p.design_date, c.name, logo.file_path, COALESCE(designer.name, owner.name), sig.file_path, - pm.name, lead.name + pm.name, lead.name, pm_sig.file_path, lead_sig.file_path FROM projects p LEFT JOIN companies c ON c.id = p.company_id LEFT JOIN users owner ON owner.id = p.user_id @@ -145,9 +153,38 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: LEFT JOIN users pm ON pm.id = p.pm_user_id LEFT JOIN users lead ON lead.id = p.field_lead_user_id LEFT JOIN company_assets logo - ON logo.id = p.logo_asset_id AND logo.deleted_at IS NULL + ON logo.id = COALESCE(p.logo_asset_id, c.logo_asset_id) + AND logo.deleted_at IS NULL LEFT JOIN company_assets sig - ON sig.id = p.signature_asset_id AND sig.deleted_at IS NULL + ON sig.id = ( + SELECT s.id + FROM company_assets s + WHERE s.user_id = COALESCE(p.designer_user_id, p.user_id) + AND s.kind = 'SIGNATURE' + AND s.deleted_at IS NULL + ORDER BY s.id DESC + LIMIT 1 + ) + LEFT JOIN company_assets pm_sig + ON pm_sig.id = ( + SELECT s.id + FROM company_assets s + WHERE s.user_id = p.pm_user_id + AND s.kind = 'SIGNATURE' + AND s.deleted_at IS NULL + ORDER BY s.id DESC + LIMIT 1 + ) + LEFT JOIN company_assets lead_sig + ON lead_sig.id = ( + SELECT s.id + FROM company_assets s + WHERE s.user_id = p.field_lead_user_id + AND s.kind = 'SIGNATURE' + AND s.deleted_at IS NULL + ORDER BY s.id DESC + LIMIT 1 + ) WHERE p.id = %s AND p.deleted_at IS NULL """, (str(project_id),), @@ -168,6 +205,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: signature_path, pm, lead, + pm_signature_path, + lead_signature_path, ) = row fields = { "공사명": name, @@ -183,6 +222,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: "분야별책임자": lead, "회사로고": _asset_data_url(logo_path), "설계자서명": _asset_data_url(signature_path), + "과업책임자서명": _asset_data_url(pm_signature_path), + "분야별책임자서명": _asset_data_url(lead_signature_path), } return {key: str(value) for key, value in fields.items() if value} @@ -234,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 @@ -261,6 +302,44 @@ async def get_design_drawing( if context is None: return JSONResponse(status_code=404, content={"status": "error", "message": reason}) source_design = await asyncio.to_thread(watershed_source, context) + elif drawing_id == CROSS_STANDARD_ID: + # 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값). + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT company_id FROM projects WHERE id = %s AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + source_design = ( + await get_project_standard_cross_section(connection, int(row[0]), project_id) + if row + else None + ) + elif LIDAR_ID.fullmatch(drawing_id): + # 라이다 계획평면도는 확정 DTM 격자로 음영기복 그림을 만들어 넘긴다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread(lidar_source, context, longitudinal, drawing_id) + elif LANDUSE_ID.fullmatch(drawing_id): + # 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread( + landuse_source, context, longitudinal, drawing_id + ) + elif PLAN_ID.fullmatch(drawing_id): + # 계획평면도는 유역도와 **같은 배경 창구**를 쓴다 — 자료 읽기·환산이 캐시된다. + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) + source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( _read_drawing, project_root, longitudinal_path, drawing_id, source_design ) @@ -423,7 +502,11 @@ async def invalidate_design_drawing( """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) - items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + # 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로, + # 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다 + # (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가). + designs = await _designs_by_chainage(route_id) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs) if drawing_id not in {item.id for item in items}: raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.") await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) @@ -433,7 +516,6 @@ async def invalidate_design_drawing( if cross_match: stale = [int(cross_match.group(1))] elif CROSS_SHEET_ID.fullmatch(drawing_id): - designs = await _designs_by_chainage(route_id) plan = await asyncio.to_thread( _cross_sheet_plan, project_root, longitudinal_path, designs ) @@ -473,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 5d0396d8..ef7bfcc3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -5,51 +5,95 @@ import json import logging -import math import re from pathlib import Path from typing import Any -from pyproj import Transformer - -from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE -from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files -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, infer_station_interval, station_no_label, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing, map_area_mm +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( build_blank_drawing, build_cover_drawing, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( + LANDUSE_LABEL, + build_landuse_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import ( + LIDAR_LABEL, + build_lidar_plan_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + PLAN_KINDS, + build_plan_drawing, + plan_chunks, + plan_drawing_id, + plan_drawing_label, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, build_cross_sheet, plan_cross_sheets, section_block_size, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import ( + STANDARD_LABEL, + build_standard_cross_drawing, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields + +# 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). +# 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로는 불변이다. +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401 + _BASIN_MAX_DISTANCE_M, + CONTOUR_FILE, + LANDUSE_ID, + LIDAR_ID, + PLAN_ID, + STREAM_FILE, + _basins_crs, + _clip_segment, + _geojson_features, + _geojson_payload, + _geometry_lines, + _too_far_from_route, + clip_line_to_box, + landuse_source, + lidar_source, + plan_source, + plan_stations, + watershed_source, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _cross_files, + _design_root, + _read_json, + _read_manifest, + _station_map, + _write_manifest, +) from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) -from common_util.common_util_drainage_pipes import detail_basins_path from common_util.common_util_route_profile import design_elevation_from_longitudinal -from config.config_system import DRAWING_SCALE_BASIN logger = logging.getLogger(__name__) + _STAGE_DIR = "B07_DesignDetail" _CROSS_ID = re.compile(r"^cross_(\d+)m$") @@ -58,308 +102,15 @@ _LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") MASS_HAUL_ID = "mass_haul" WATERSHED_ID = "watershed" COVER_ID = "cover" +# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. +CROSS_STANDARD_ID = "cross_standard" # 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). # 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. -BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( - ("blank_plan_terrain", "계획평면도(지형)"), - ("blank_plan_route", "계획평면도(노선배치도)"), - ("blank_plan_layout", "계획평면도(배치도)"), - ("blank_plan_lidar", "계획평면도(라이다)"), - ("blank_cross_standard", "표준 횡단면도"), - ("blank_standard", "표준도"), - ("blank_landuse", "용지도"), -) +BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),) BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) -def _read_json(path: Path) -> dict[str, Any]: - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") - return payload - - -def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: - cross_dir = longitudinal_path.parent.parent / "cross_sections" - if not cross_dir.is_dir(): - raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") - stations = longitudinal.get("stations") - valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) - files = sorted(cross_dir.glob("cross_*.json")) - if valid_names: - files = [path for path in files if path.name in valid_names] - return files - - -def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: - stations = longitudinal.get("stations", []) - if not isinstance(stations, list): - return {} - return { - round(float(station.get("chainage_m", 0))): station - for station in stations - if isinstance(station, dict) - } - - -def _design_root(project_root: Path) -> Path: - return project_root / _STAGE_DIR - - -def _read_manifest(project_root: Path) -> dict[str, Any]: - path = _design_root(project_root) / "manifest.json" - if not path.is_file(): - return {"drawings": {}} - payload = _read_json(path) - return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} - - -def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: - stage_root = _design_root(project_root) - stage_root.mkdir(parents=True, exist_ok=True) - path = stage_root / "manifest.json" - temporary = path.with_suffix(".tmp") - temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - temporary.replace(path) - - -def _geojson_payload(path: Path) -> dict[str, Any]: - """GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict.""" - if not path.is_file(): - return {} - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path) - return {} - return payload if isinstance(payload, dict) else {} - - -def _geojson_features(path: Path) -> list[dict[str, Any]]: - """GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록.""" - features = _geojson_payload(path).get("features") - return features if isinstance(features, list) else [] - - -def _basins_crs(context: Any, payload: dict[str, Any]) -> str: - """저장본을 미터로 되돌릴 좌표계. - - 저장할 때 쓴 좌표계를 파일이 갖고 있으면 그 값이다. 없으면 좌표계 기록 이전에 저장된 - 파일이라 노선 CSV의 EPSG 라벨로 쓰였다 — 그 라벨로 되돌려야 왕복이 맞는다 - (2026-09-01). 라벨을 못 읽으면 현재 사업지 좌표계로 둔다. - """ - from common_util.common_util_route_geometry import ( - find_planned_route_file, - read_planned_route, - ) - - stored = payload.get("crs_input") - if isinstance(stored, str) and stored: - return stored - route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input") - planned = read_planned_route(route_file) if route_file else None - if planned is not None and planned.epsg: - logger.warning( - "B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. " - "B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.", - planned.epsg, - ) - return f"EPSG:{planned.epsg}" - return context.crs - - -def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: - """LineString·MultiLineString·Polygon을 점열 목록으로 편다.""" - if not isinstance(geometry, dict): - return [] - kind = geometry.get("type") - coordinates = geometry.get("coordinates") - if not isinstance(coordinates, list) or not coordinates: - return [] - if kind == "LineString": - return [[(float(p[0]), float(p[1])) for p in coordinates if isinstance(p, list)]] - if kind in ("MultiLineString", "Polygon"): - return [ - [(float(p[0]), float(p[1])) for p in part if isinstance(p, list)] - for part in coordinates - if isinstance(part, list) - ] - return [] - - -def _clip_segment( - start: tuple[float, float], - end: tuple[float, float], - box: tuple[float, float, float, float], -) -> tuple[tuple[float, float], tuple[float, float]] | None: - """선분에서 상자 안에 드는 부분만 돌려준다(Liang-Barsky). 겹치지 않으면 None.""" - min_x, min_y, max_x, max_y = box - dx = end[0] - start[0] - dy = end[1] - start[1] - t0, t1 = 0.0, 1.0 - for numerator, denominator in ( - (start[0] - min_x, -dx), - (max_x - start[0], dx), - (start[1] - min_y, -dy), - (max_y - start[1], dy), - ): - if denominator == 0.0: - if numerator < 0.0: - return None # 경계와 나란한데 바깥이다 - continue - t = numerator / denominator - if denominator < 0.0: - if t > t1: - return None - t0 = max(t0, t) - else: - if t < t0: - return None - t1 = min(t1, t) - return ( - (start[0] + t0 * dx, start[1] + t0 * dy), - (start[0] + t1 * dx, start[1] + t1 * dy), - ) - - -def clip_line_to_box( - line: list[tuple[float, float]], box: tuple[float, float, float, float] -) -> list[list[tuple[float, float]]]: - """범위 안에 든 부분만 조각으로 잘라 낸다 — 끝점은 경계 위에 정확히 놓인다. - - 도엽 등고선 한 줄은 도엽 끝까지 이어지므로, 걸치면 통째로 남기면 도면이 A1을 넘는다. - 예전 구현은 경계 바깥 점을 하나씩 물고 나와 외곽선이 들쭉날쭉했다(2026-08-31 사용자 - 지적). 이제 교차점을 계산해 자르므로 절취면이 곧게 떨어진다. - """ - runs: list[list[tuple[float, float]]] = [] - current: list[tuple[float, float]] = [] - for start, end in zip(line, line[1:]): - piece = _clip_segment(start, end, box) - if piece is None: - current = [] - continue - head, tail = piece - if head == tail: - continue # 경계에 점으로만 닿았다 - if current and current[-1] == head: - current.append(tail) - else: - current = [head, tail] - runs.append(current) - return [run for run in runs if len(run) >= 2] - - -# 세부유역이 노선에서 이만큼 넘게 떨어져 있으면 좌표계를 잘못 되돌린 것으로 본다. -# 임도 한 노선이 담는 유역은 길어야 수 km라 오검출 여지가 없다. -_BASIN_MAX_DISTANCE_M = 50_000.0 - - -def _too_far_from_route( - ring: list[tuple[float, float]], route_xy: list[tuple[float, float]] -) -> bool: - """되돌린 유역이 노선 근처에 없으면 True — 좌표계를 되찾지 못한 저장본이다.""" - if not ring or not route_xy: - return False - cx = sum(x for x, _ in ring) / len(ring) - cy = sum(y for _, y in ring) / len(ring) - return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M - - -def watershed_source(context: Any) -> dict[str, Any]: - """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. - - 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). - - 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — - 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역은 - 그 파일을 쓸 때 쓴 좌표계**로 돌린다 — 좌표계 기록 이전 저장본은 노선 CSV의 EPSG - 라벨로 쓰였고, 그 라벨로 되돌려야 원래 미터 좌표가 나온다(2026-09-01). - """ - basins_payload = _geojson_payload(detail_basins_path(context.stored_path)) - to_metric = Transformer.from_crs("EPSG:4326", context.crs, always_xy=True) - to_basin_metric = Transformer.from_crs( - "EPSG:4326", _basins_crs(context, basins_payload), always_xy=True - ) - - def metric(point: tuple[float, float]) -> tuple[float, float]: - x, y = to_metric.transform(point[0], point[1]) - return (float(x), float(y)) - - def basin_metric(point: tuple[float, float]) -> tuple[float, float]: - x, y = to_basin_metric.transform(point[0], point[1]) - return (float(x), float(y)) - - route_xy = [(vertex.x, vertex.y) for vertex in context.vertices] - basins: list[dict[str, Any]] = [] - dropped = 0 - for feature in basins_payload.get("features") or []: - properties = feature.get("properties") or {} - if properties.get("kind") != "detail_basin": - continue - rings = _geometry_lines(feature.get("geometry")) - if not rings: - continue - ring = [basin_metric(point) for point in rings[0]] - # 저장본 좌표계를 못 되찾으면 유역이 노선에서 수백 km 밖으로 떨어진다. 그대로 두면 - # 도곽이 그 거리까지 벌어져 도면이 통째로 빈 화면이 된다 — 유역만 버리고 배경·노선은 - # 그린다(2026-09-01 다른 PC 보고: 유역도 그림 자체가 없음). - if _too_far_from_route(ring, route_xy): - dropped += 1 - continue - basins.append({"ring": ring, "props": properties}) - if dropped: - logger.warning( - "B07 유역도: 노선에서 %.0fkm 넘게 떨어진 세부유역 %d개를 뺐습니다 — " - "저장본 좌표계를 되찾지 못했습니다. B04에서 유역을 다시 확정하세요.", - _BASIN_MAX_DISTANCE_M / 1000.0, - dropped, - ) - - # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 - # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). - # 도엽 등고선 한 줄은 도엽 끝까지 이어지므로 "걸치면 통째로"는 도면이 A1을 넘긴다 - # (실측 430x871 mm). 절취 범위 = 도곽 안 지형 영역(정보표·제목 제외)을 축척으로 되돌린 크기. - usable_w_mm, usable_h_mm = map_area_mm() - half_w_m = usable_w_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0 - half_h_m = usable_h_mm / 2.0 * DRAWING_SCALE_BASIN / 1000.0 - extent = [*route_xy, *(point for basin in basins for point in basin["ring"])] - if extent: - center_x = (min(x for x, _y in extent) + max(x for x, _y in extent)) / 2.0 - center_y = (min(y for _x, y in extent) + max(y for _x, y in extent)) / 2.0 - # 노선·유역이 도곽보다 크면 그쪽을 우선한다 — 배경만 잘리고 주제는 다 보인다. - min_x = min(center_x - half_w_m, min(x for x, _y in extent)) - max_x = max(center_x + half_w_m, max(x for x, _y in extent)) - min_y = min(center_y - half_h_m, min(y for _x, y in extent)) - max_y = max(center_y + half_h_m, max(y for _x, y in extent)) - else: - min_x = min_y = -math.inf - max_x = max_y = math.inf - - box = (min_x, min_y, max_x, max_y) - - def clip(line: list[tuple[float, float]]) -> list[list[tuple[float, float]]]: - return clip_line_to_box(line, box) - - sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed" - background: dict[str, list[list[tuple[float, float]]]] = {} - for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)): - lines: list[list[tuple[float, float]]] = [] - for feature in _geojson_features(sheet_dir / filename): - for part in _geometry_lines(feature.get("geometry")): - converted = [metric(point) for point in part] - if len(converted) >= 2: - lines.extend(clip(converted)) - background[key] = lines - - return { - "route_xy": route_xy, - "basins": basins, - "contours": background["contours"], - "streams": background["streams"], - } - - def _drawing_list( project_root: Path, longitudinal_path: Path, @@ -385,24 +136,65 @@ def _drawing_list( except (FileNotFoundError, ValueError, OSError) as exc: logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc) sheets = [] + # 장 라벨은 누가거리 범위가 아니라 **그 장의 시작 측점**으로 짓는다(2026-09-04 사용자 + # 지시) — 「1060~1070m」식 범위는 좌측 패널 2열 단추를 넘칠 만큼 길었다. + interval = infer_station_interval(longitudinal.get("stations") or []) for sheet in sheets: chainages = sheet["chainages"] first = station_by_chainage.get(chainages[0], {}) - span = f"{chainages[0]}m" - if len(chainages) > 1: - span = f"{chainages[0]}~{chainages[-1]}m" + station = station_no_label(float(first.get("chainage_m", chainages[0])), interval) drawings.append( DesignDrawingItem( id=sheet["id"], kind="cross", - label=f"{sheet['number']}장 ({span})", + label=f"{sheet['number']}장 ({station})", chainage_m=float(first.get("chainage_m", chainages[0])), confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")), ) ) + # 계획평면도 3종 — 축척 1/1,200 고정이라 노선이 길면 장이 나뉜다(장수는 노선이 정한다). + plan_sheets = plan_chunks(plan_stations(longitudinal)) + for kind, _label, *_rest in PLAN_KINDS: + for chunk in plan_sheets: + drawing_id = plan_drawing_id(kind, chunk, len(plan_sheets)) + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="plan", + label=plan_drawing_label(kind, chunk, len(plan_sheets)), + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) + # 계획평면도(라이다) — 지표면 음영기복 배경. 같은 축척·같은 장 나눔. + for chunk in plan_sheets: + drawing_id = "plan_lidar" if len(plan_sheets) <= 1 else f"plan_lidar_{chunk['number']}" + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="plan_lidar", + label=LIDAR_LABEL + if len(plan_sheets) <= 1 + else f"{LIDAR_LABEL} {chunk['number']}장", + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) + # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. + for chunk in plan_sheets: + drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" + drawings.append( + DesignDrawingItem( + id=drawing_id, + kind="landuse", + label=LANDUSE_LABEL + if len(plan_sheets) <= 1 + else f"{LANDUSE_LABEL} {chunk['number']}장", + confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + ) + ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), + (CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL), (MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"), (WATERSHED_ID, "watershed", "유역도(배수 유역도)"), ): @@ -466,12 +258,18 @@ def _cross_sheet_plan( return plan_cross_sheets(blocks) -def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: +def _quantity_table( + source: dict[str, Any], design: dict[str, Any] | None = None +) -> dict[str, float | None]: """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키). center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값. 나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로 두어 CAD 테이블에서 사용자가 채운다. + + **계획고는 횡단 설계(design)에도 있다** — 장 배치 입력의 원본에는 그 값이 없어 + 계획고·절토고·성토고 세 칸이 통째로 비어 나갔다(2026-09-03 실측: 장 확정 시 21개 + 항목 중 지반고 하나만 채워짐). 원본에 없으면 설계에서 읽는다. """ def num(value: Any) -> float | None: @@ -479,6 +277,8 @@ def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: ground = num(source.get("center_z")) planned = num(source.get("planned_elevation_m", source.get("design_elevation_m"))) + if planned is None and isinstance(design, dict): + planned = num(design.get("design_elevation_m")) cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} @@ -545,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): @@ -568,7 +369,7 @@ def _cross_section_input( "source": source, "design": design, "design_line": _cross_design_line(longitudinal_path, source, design), - "quantity_table": _quantity_table(source), + "quantity_table": _quantity_table(source, design), "title": station_no_label(float(source.get("chainage_m", chainage)), interval), } @@ -620,8 +421,14 @@ def _read_drawing( # 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고 # 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도. if saved.get("format") == DRAWING_FORMAT: - if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID): + if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID): kind = drawing_id # id와 kind가 같은 단장 도면 + elif PLAN_ID.fullmatch(drawing_id): + kind = "plan" + elif LANDUSE_ID.fullmatch(drawing_id): + kind = "landuse" + elif LIDAR_ID.fullmatch(drawing_id): + kind = "plan_lidar" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -650,6 +457,82 @@ def _read_drawing( None, ) + if drawing_id == CROSS_STANDARD_ID: + # stored_design = B06 표준 횡단면 설정값(라우터가 실어 준다). 없으면 config 기본값. + standard = stored_design if isinstance(stored_design, dict) else None + return ( + "cross_standard", + STANDARD_LABEL, + build_standard_cross_drawing(drawing_id, STANDARD_LABEL, standard), + False, + None, + ) + + if LIDAR_ID.fullmatch(drawing_id): + # stored_design = lidar_source()가 만든 노선 + 지표면 음영기복 그림. + if not isinstance(stored_design, dict): + raise FileNotFoundError("라이다 계획평면도 자료가 없습니다.") + label = str(stored_design.get("label") or LIDAR_LABEL) + return ( + "plan_lidar", + label, + build_lidar_plan_drawing( + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("shade_image"), + stored_design.get("shade_box"), + ), + False, + None, + ) + + if LANDUSE_ID.fullmatch(drawing_id): + # stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS). + if not isinstance(stored_design, dict): + raise FileNotFoundError("용지도 자료가 없습니다.") + label = str(stored_design.get("label") or LANDUSE_LABEL) + return ( + "landuse", + label, + build_landuse_drawing( + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("contours") or [], + stored_design.get("parcels") or [], + stored_design.get("emd_rings") or [], + stored_design.get("sgg_rings") or [], + ), + False, + None, + ) + + if PLAN_ID.fullmatch(drawing_id): + # stored_design = plan_source()가 모아 준 노선·측점·배경·구조물 좌표(사업지 CRS). + if not isinstance(stored_design, dict): + raise FileNotFoundError("계획평면도 자료가 없습니다.") + longitudinal = _read_json(longitudinal_path) + interval = infer_station_interval(longitudinal.get("stations") or []) + label = str(stored_design.get("label") or drawing_id) + return ( + "plan", + label, + build_plan_drawing( + str(stored_design.get("kind") or "plan_terrain"), + drawing_id, + label, + stored_design.get("route_xy") or [], + stored_design.get("stations") or [], + stored_design.get("contours") or [], + stored_design.get("streams") or [], + stored_design.get("structures") or [], + interval, + ), + False, + None, + ) + if drawing_id == WATERSHED_ID: # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). if not isinstance(stored_design, dict): @@ -697,7 +580,7 @@ def _read_drawing( source = _read_json(path) label = str(source.get("label") or drawing_id) design_line = _cross_design_line(longitudinal_path, source, stored_design) - quantity_table = _quantity_table(source) + quantity_table = _quantity_table(source, stored_design) # 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식) longitudinal = _read_json(longitudinal_path) interval = infer_station_interval(longitudinal.get("stations") or []) @@ -795,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_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py new file mode 100644 index 00000000..7858a53f --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -0,0 +1,649 @@ +"""B07 유역도 배경 자료 — GeoJSON 읽기·좌표계 환산·도곽 클리핑. + +지원 모듈이 700줄을 넘어 떼어냈다(2026-09-04). 도면 목록·확정 저장은 본체에 남는다. +""" + +import json +import logging +import math +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +from pyproj import Transformer + +from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import LIDAR_LABEL, hillshade_png +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( + plan_area_mm, + plan_chunks, + plan_drawing_label, +) +from common_util.common_util_drainage_pipes import detail_basins_path +from config.config_system import DRAWING_SCALE_BASIN, DRAWING_SCALE_PLAN + +logger = logging.getLogger(__name__) + + +# 도면 id 상수·빈 도면 목록은 `B07_DesignDetail_Router_Support` 한 곳이 정본이다. +# 이 모듈에 있던 같은 이름의 사본은 아무도 읽지 않으면서 값만 어긋나 지웠다(2026-09-04). + + +def _geojson_payload(path: Path) -> dict[str, Any]: + """GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict.""" + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path) + return {} + return payload if isinstance(payload, dict) else {} + + +def _geojson_features(path: Path) -> list[dict[str, Any]]: + """GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록.""" + features = _geojson_payload(path).get("features") + return features if isinstance(features, list) else [] + + +def _basins_crs(context: Any, payload: dict[str, Any]) -> str: + """저장본을 미터로 되돌릴 좌표계. + + 저장할 때 쓴 좌표계를 파일이 갖고 있으면 그 값이다. 없으면 좌표계 기록 이전에 저장된 + 파일이라 노선 CSV의 EPSG 라벨로 쓰였다 — 그 라벨로 되돌려야 왕복이 맞는다 + (2026-09-01). 라벨을 못 읽으면 현재 사업지 좌표계로 둔다. + """ + from common_util.common_util_route_geometry import ( + find_planned_route_file, + read_planned_route, + ) + + stored = payload.get("crs_input") + if isinstance(stored, str) and stored: + return stored + route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input") + planned = read_planned_route(route_file) if route_file else None + if planned is not None and planned.epsg: + logger.warning( + "B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. " + "B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.", + planned.epsg, + ) + return f"EPSG:{planned.epsg}" + return context.crs + + +def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: + """LineString·MultiLineString·Polygon·MultiPolygon을 점열 목록으로 편다.""" + if not isinstance(geometry, dict): + return [] + kind = geometry.get("type") + coordinates = geometry.get("coordinates") + if not isinstance(coordinates, list) or not coordinates: + return [] + if kind == "LineString": + return [[(float(p[0]), float(p[1])) for p in coordinates if isinstance(p, list)]] + if kind in ("MultiLineString", "Polygon"): + return [ + [(float(p[0]), float(p[1])) for p in part if isinstance(p, list)] + for part in coordinates + if isinstance(part, list) + ] + # 연속지적도·행정구역은 MultiPolygon 이다 — 폴리곤마다 고리를 모두 편다(2026-09-04). + if kind == "MultiPolygon": + return [ + [(float(p[0]), float(p[1])) for p in ring if isinstance(p, list)] + for polygon in coordinates + if isinstance(polygon, list) + for ring in polygon + if isinstance(ring, list) + ] + return [] + + +def _clip_segment( + start: tuple[float, float], + end: tuple[float, float], + box: tuple[float, float, float, float], +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """선분에서 상자 안에 드는 부분만 돌려준다(Liang-Barsky). 겹치지 않으면 None.""" + min_x, min_y, max_x, max_y = box + dx = end[0] - start[0] + dy = end[1] - start[1] + t0, t1 = 0.0, 1.0 + for numerator, denominator in ( + (start[0] - min_x, -dx), + (max_x - start[0], dx), + (start[1] - min_y, -dy), + (max_y - start[1], dy), + ): + if denominator == 0.0: + if numerator < 0.0: + return None # 경계와 나란한데 바깥이다 + continue + t = numerator / denominator + if denominator < 0.0: + if t > t1: + return None + t0 = max(t0, t) + else: + if t < t0: + return None + t1 = min(t1, t) + return ( + (start[0] + t0 * dx, start[1] + t0 * dy), + (start[0] + t1 * dx, start[1] + t1 * dy), + ) + + +def clip_line_to_box( + line: list[tuple[float, float]], box: tuple[float, float, float, float] +) -> list[list[tuple[float, float]]]: + """범위 안에 든 부분만 조각으로 잘라 낸다 — 끝점은 경계 위에 정확히 놓인다. + + 도엽 등고선 한 줄은 도엽 끝까지 이어지므로, 걸치면 통째로 남기면 도면이 A1을 넘는다. + 예전 구현은 경계 바깥 점을 하나씩 물고 나와 외곽선이 들쭉날쭉했다(2026-08-31 사용자 + 지적). 이제 교차점을 계산해 자르므로 절취면이 곧게 떨어진다. + """ + runs: list[list[tuple[float, float]]] = [] + current: list[tuple[float, float]] = [] + for start, end in zip(line, line[1:]): + piece = _clip_segment(start, end, box) + if piece is None: + current = [] + continue + head, tail = piece + if head == tail: + continue # 경계에 점으로만 닿았다 + if current and current[-1] == head: + current.append(tail) + else: + current = [head, tail] + runs.append(current) + return [run for run in runs if len(run) >= 2] + + +# 세부유역이 노선에서 이만큼 넘게 떨어져 있으면 좌표계를 잘못 되돌린 것으로 본다. +# 임도 한 노선이 담는 유역은 길어야 수 km라 오검출 여지가 없다. +_BASIN_MAX_DISTANCE_M = 50_000.0 + + +def _too_far_from_route( + ring: list[tuple[float, float]], route_xy: list[tuple[float, float]] +) -> bool: + """되돌린 유역이 노선 근처에 없으면 True — 좌표계를 되찾지 못한 저장본이다.""" + if not ring or not route_xy: + return False + cx = sum(x for x, _ in ring) / len(ring) + cy = sum(y for _, y in ring) / len(ring) + return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M + + +@lru_cache(maxsize=8) +def _metric_lines_cached( + path_str: str, mtime_ns: int, crs: str +) -> tuple[tuple[tuple[float, float], ...], ...]: + """도엽 GeoJSON 한 벌을 사업지 좌표계(m) 선 목록으로 돌려 **캐시**한다. + + 같은 배경을 유역도와 계획평면도가 나눠 쓴다 — 도면마다 다시 읽고 다시 투영하면 + 한 장 여는 데 수 초가 걸린다(등고선 수만 점). 파일이 바뀌면 mtime 이 달라져 + 캐시가 저절로 갈린다(`_read_template` 와 같은 방식). + """ + to_metric = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + lines: list[tuple[tuple[float, float], ...]] = [] + for feature in _geojson_features(Path(path_str)): + for part in _geometry_lines(feature.get("geometry")): + converted = tuple( + (float(x), float(y)) + for x, y in (to_metric.transform(point[0], point[1]) for point in part) + ) + if len(converted) >= 2: + lines.append(converted) + return tuple(lines) + + +def _metric_lines(path: Path, crs: str) -> list[list[tuple[float, float]]]: + """캐시된 배경 선을 쓰기 좋은 형태로 낸다. 파일이 없으면 빈 목록.""" + if not path.is_file(): + return [] + cached = _metric_lines_cached(str(path), path.stat().st_mtime_ns, crs) + return [list(line) for line in cached] + + +def map_background( + project_root: Path, + crs: str, + scale: int, + area_mm: tuple[float, float], + extent_points: list[tuple[float, float]], +) -> dict[str, list[list[tuple[float, float]]]]: + """도엽 등고선·세류선을 사업지 좌표계로 읽어 **그 도면의 도곽 크기로 절취**한다. + + 유역도·계획평면도·용지도가 같은 창구를 쓴다 — 자료 읽기·좌표 환산은 한 번뿐이고 + (`_metric_lines` 캐시), 도면마다 다른 것은 축척과 도곽 크기뿐이다. + + `extent_points` 는 그 도면의 주제(노선·유역 등) 좌표다. 도곽보다 크면 그쪽을 + 우선한다 — 배경만 잘리고 주제는 다 보인다. + """ + area_w_mm, area_h_mm = area_mm + half_w_m = area_w_mm / 2.0 * scale / 1000.0 + half_h_m = area_h_mm / 2.0 * scale / 1000.0 + if extent_points: + center_x = (min(x for x, _y in extent_points) + max(x for x, _y in extent_points)) / 2.0 + center_y = (min(y for _x, y in extent_points) + max(y for _x, y in extent_points)) / 2.0 + box = ( + min(center_x - half_w_m, min(x for x, _y in extent_points)), + min(center_y - half_h_m, min(y for _x, y in extent_points)), + max(center_x + half_w_m, max(x for x, _y in extent_points)), + max(center_y + half_h_m, max(y for _x, y in extent_points)), + ) + else: + box = (-math.inf, -math.inf, math.inf, math.inf) + + sheet_dir = Path(project_root) / "B04_PreProcess" / "processed" + background: dict[str, list[list[tuple[float, float]]]] = {} + for key, filename in (("contours", CONTOUR_FILE), ("streams", STREAM_FILE)): + lines: list[list[tuple[float, float]]] = [] + for line in _metric_lines(sheet_dir / filename, crs): + lines.extend(clip_line_to_box(line, box)) + background[key] = lines + return background + + +PLAN_ID = re.compile(r"^(plan_terrain|plan_route|plan_layout)(?:_(\d+))?$") + + +def plan_stations(longitudinal: dict[str, Any]) -> list[tuple[float, float, float]]: + """종단 측점의 (누가거리 m, x, y) — 계획평면도 장 나눔의 유일한 기준 자료.""" + stations: list[tuple[float, float, float]] = [] + for station in longitudinal.get("stations") or []: + if not isinstance(station, dict): + continue + chainage = station.get("chainage_m") + x, y = station.get("center_x"), station.get("center_y") + if all(isinstance(value, (int, float)) for value in (chainage, x, y)): + stations.append((float(chainage), float(x), float(y))) + return stations + + +def plan_chunk_for( + longitudinal: dict[str, Any], drawing_id: str +) -> tuple[str, dict[str, Any], int]: + """도면 id 에서 (주제, 그 장의 구간, 전체 장수)를 찾는다.""" + match = PLAN_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 계획평면도 ID입니다.") + kind = match.group(1) + chunks = plan_chunks(plan_stations(longitudinal)) + number = int(match.group(2)) if match.group(2) else 1 + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 계획평면도 장을 찾을 수 없습니다.") + return kind, chunk, len(chunks) + + +def plan_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """계획평면도 한 장의 입력(노선·측점·등고선·세류선·구조물)을 사업지 CRS(m)로 모은다. + + 배경은 유역도와 **같은 창구**(`map_background`)를 쓴다 — 도엽 GeoJSON 읽기·좌표 + 환산이 캐시돼 두 도면이 자료를 나눠 쓴다(2026-09-04 사용자 지시). + + 장이 여럿이면 그 장의 구간(누가거리)에 드는 노선·구조물만 싣고, 배경도 그 범위로 + 절취한다 — 축척 1/1,200 은 고정이므로 안 들어가면 장을 나눈다. + """ + kind, chunk, total = plan_chunk_for(longitudinal, drawing_id) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + + route_xy: list[tuple[float, float]] = [] + for vertex in context.vertices: + chainage = float(getattr(vertex, "chainage_m", 0.0) or 0.0) + if total > 1 and not (start_m <= chainage <= end_m): + continue + route_xy.append((vertex.x, vertex.y)) + # 측점 눈금은 **종단 측점**을 쓴다 — 노선 정점은 조밀하고 누가거리가 간격의 배수가 아니다. + stations = [ + station + for station in plan_stations(longitudinal) + if total <= 1 or start_m <= station[0] <= end_m + ] + + structures = [ + structure + for structure in _plan_structures(context) + if total <= 1 or start_m <= float(structure.get("chainage_m") or -1.0) <= end_m + ] + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_PLAN, + plan_area_mm(), + route_xy, + ) + return { + "kind": kind, + "label": plan_drawing_label(kind, chunk, total), + "route_xy": route_xy, + "stations": stations, + "contours": background["contours"], + "streams": background["streams"], + "structures": structures, + } + + +def _plan_structures(context: Any) -> list[dict[str, Any]]: + """배치도에 찍을 구조물 — B04 배수시설 정본(`pipe_points.json`)을 그대로 읽는다. + + 좌표는 이미 사업지 CRS(m)다(B04가 그렇게 쓴다). 없으면 빈 목록 — 배치도는 배경과 + 노선만으로도 열린다. + """ + path = Path(context.project_root) / "B04_PreProcess" / "drainage" / "edits" / "pipe_points.json" + points = _geojson_payload(path).get("points") + if not isinstance(points, list): + return [] + return [point for point in points if isinstance(point, dict)] + + +LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$") + +# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). +PARCEL_FILE = "연속지적도_bounds.geojson" +EMD_FILE = "행정구역_읍면동_bounds.geojson" +SGG_FILE = "행정구역_시군구_bounds.geojson" + + +def _clip_rings( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[list[tuple[float, float]]]: + """행정구역 경계를 사업지 좌표계로 돌려 도곽 범위로 절취한다(속성은 안 씀).""" + rings: list[list[tuple[float, float]]] = [] + for ring in _metric_lines(path, crs): + rings.extend(clip_line_to_box(ring, box)) + return rings + + +def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool: + """점이 고리 안에 드는지 (반직선 교차 판정).""" + x, y = point + inside = False + for index in range(len(ring)): + x1, y1 = ring[index - 1] + x2, y2 = ring[index] + if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1: + inside = not inside + return inside + + +def _clip_parcels( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[dict[str, Any]]: + """연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함). + + 필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 — + 피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다. + + 두 가지를 함께 낸다. + - `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면 + 산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm). + - `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이 + 들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측: + 용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감). + """ + if not path.is_file(): + return [] + transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + min_x, min_y, max_x, max_y = box + center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + parcels: list[dict[str, Any]] = [] + for feature in _geojson_features(path): + properties = feature.get("properties") or {} + for ring in _geometry_lines(feature.get("geometry")): + converted = [ + (float(x), float(y)) + for x, y in (transformer.transform(point[0], point[1]) for point in ring) + ] + if len(converted) < 3: + continue + if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x: + continue + if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y: + continue + parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2] + for part in parts: + parcels.append({"ring": part, "props": properties}) + if not parts and _contains(converted, center): + parcels.append({"ring": [], "props": properties, "label_at": center}) + return parcels + + +def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다. + + 축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다. + """ + match = LANDUSE_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 용지도 ID입니다.") + chunks = plan_chunks(plan_stations(longitudinal)) + number = int(match.group(1)) if match.group(1) else 1 + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 용지도 장을 찾을 수 없습니다.") + total = len(chunks) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + + route_xy = [ + (vertex.x, vertex.y) + for vertex in context.vertices + if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m + ] + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_PLAN, + plan_area_mm(), + route_xy, + ) + # 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다. + area_w_mm, area_h_mm = plan_area_mm() + half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 + center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 + box = ( + min(center_x - half_w, min(x for x, _y in route_xy)), + min(center_y - half_h, min(y for _x, y in route_xy)), + max(center_x + half_w, max(x for x, _y in route_xy)), + max(center_y + half_h, max(y for _x, y in route_xy)), + ) + sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed" + label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장" + return { + "label": label, + "route_xy": route_xy, + "contours": background["contours"], + "parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box), + "emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box), + "sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box), + } + + +LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$") + + +def _sheet_box( + route_xy: list[tuple[float, float]], +) -> tuple[float, float, float, float]: + """그 장의 도곽 범위(실좌표 m) — 계획평면도·용지도·라이다가 같은 규칙을 쓴다.""" + area_w_mm, area_h_mm = plan_area_mm() + half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 + center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 + center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 + return ( + min(center_x - half_w, min(x for x, _y in route_xy)), + min(center_y - half_h, min(y for _x, y in route_xy)), + max(center_x + half_w, max(x for x, _y in route_xy)), + max(center_y + half_h, max(y for _x, y in route_xy)), + ) + + +def _chunk_route( + context: Any, longitudinal: dict[str, Any], number: int +) -> tuple[list[tuple[float, float]], dict[str, Any], int]: + """장 번호로 그 장의 노선 구간을 잘라 낸다 (계획평면도 장 나눔과 같은 기준).""" + chunks = plan_chunks(plan_stations(longitudinal)) + chunk = next((item for item in chunks if item["number"] == number), None) + if chunk is None: + raise FileNotFoundError("요청한 장을 찾을 수 없습니다.") + total = len(chunks) + start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) + route_xy = [ + (vertex.x, vertex.y) + for vertex in context.vertices + if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m + ] + return route_xy, chunk, total + + +def lidar_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: + """라이다 계획평면도 한 장의 입력(노선 + 지표면 음영기복 그림)을 모은다. + + 지표면은 확정 DTM 격자(`dtm_{필터}[_smooth].npz`)를 도곽 범위로 잘라 쓴다 — + 점구름을 그대로 그리면 수천만 점이라 도면 만들기가 느려진다(2026-09-04 사용자 지시). + """ + match = LIDAR_ID.fullmatch(drawing_id) + if not match: + raise ValueError("올바르지 않은 라이다 계획평면도 ID입니다.") + number = int(match.group(1)) if match.group(1) else 1 + route_xy, chunk, total = _chunk_route(context, longitudinal, number) + box = _sheet_box(route_xy) + label = LIDAR_LABEL if total <= 1 else f"{LIDAR_LABEL} {chunk['number']}장" + + shade_image: str | None = None + shade_box: tuple[float, float, float, float] | None = None + try: + shade_image, shade_box = _hillshade_for_box(context, box) + except (FileNotFoundError, ValueError, OSError) as exc: + # 지표면이 없어도 노선·도각은 그린다 — 빈 화면보다 낫다. + logger.warning("B07 라이다 계획평면도: 음영기복을 만들지 못했습니다 — %s", exc) + + return { + "label": label, + "route_xy": route_xy, + "shade_image": shade_image, + "shade_box": shade_box, + } + + +def _hillshade_for_box( + context: Any, box: tuple[float, float, float, float] +) -> tuple[str, tuple[float, float, float, float]]: + """확정 DTM 격자를 도곽 범위로 잘라 음영기복 PNG(data URL)와 실제 덮은 범위를 낸다.""" + import numpy as np + + params = getattr(context, "surface_params", None) or {} + source_filter = str(params.get("source_filter") or "csf") + smooth = bool(params.get("smooth", True)) + models_dir = Path(context.project_root) / "B04_PreProcess" / "models" + candidates = [models_dir / f"dtm_{source_filter}_smooth.npz"] if smooth else [] + candidates.append(models_dir / f"dtm_{source_filter}.npz") + candidates.extend(sorted(models_dir.glob("dtm_*_smooth.npz"))) + candidates.extend(sorted(models_dir.glob("dtm_*.npz"))) + path = next((item for item in candidates if item.is_file()), None) + if path is None: + raise FileNotFoundError("확정 지표면 격자(DTM)가 없습니다.") + + with np.load(path, allow_pickle=False) as data: + grid_x = np.asarray(data["x"], dtype=np.float64) + grid_y = np.asarray(data["y"], dtype=np.float64) + grid_z = np.asarray(data["z"], dtype=np.float64) + valid = np.asarray(data["valid_mask"], dtype=bool) + resolution = float(np.asarray(data["resolution"]).reshape(-1)[0]) + + min_x, min_y, max_x, max_y = box + columns = np.where((grid_x >= min_x) & (grid_x <= max_x))[0] + rows = np.where((grid_y >= min_y) & (grid_y <= max_y))[0] + if columns.size < 2 or rows.size < 2: + raise ValueError("도곽 안에 지표면 격자가 없습니다.") + sliced_z = grid_z[rows[0] : rows[-1] + 1, columns[0] : columns[-1] + 1] + sliced_valid = valid[rows[0] : rows[-1] + 1, columns[0] : columns[-1] + 1] + + data_url, _width, _height = hillshade_png(sliced_z, sliced_valid, resolution) + return ( + data_url, + ( + float(grid_x[columns[0]]), + float(grid_y[rows[0]]), + float(grid_x[columns[-1]]), + float(grid_y[rows[-1]]), + ), + ) + + +def watershed_source(context: Any) -> dict[str, Any]: + """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. + + 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). + + 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — + 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다(그 환산은 + `map_background()` 안에 있다). **세부유역은 그 파일을 쓸 때 쓴 좌표계**로 돌린다 — + 좌표계 기록 이전 저장본은 노선 CSV의 EPSG 라벨로 쓰였고, 그 라벨로 되돌려야 원래 + 미터 좌표가 나온다(2026-09-01). + """ + basins_payload = _geojson_payload(detail_basins_path(context.stored_path)) + to_basin_metric = Transformer.from_crs( + "EPSG:4326", _basins_crs(context, basins_payload), always_xy=True + ) + + def basin_metric(point: tuple[float, float]) -> tuple[float, float]: + x, y = to_basin_metric.transform(point[0], point[1]) + return (float(x), float(y)) + + route_xy = [(vertex.x, vertex.y) for vertex in context.vertices] + basins: list[dict[str, Any]] = [] + dropped = 0 + for feature in basins_payload.get("features") or []: + properties = feature.get("properties") or {} + if properties.get("kind") != "detail_basin": + continue + rings = _geometry_lines(feature.get("geometry")) + if not rings: + continue + ring = [basin_metric(point) for point in rings[0]] + # 저장본 좌표계를 못 되찾으면 유역이 노선에서 수백 km 밖으로 떨어진다. 그대로 두면 + # 도곽이 그 거리까지 벌어져 도면이 통째로 빈 화면이 된다 — 유역만 버리고 배경·노선은 + # 그린다(2026-09-01 다른 PC 보고: 유역도 그림 자체가 없음). + if _too_far_from_route(ring, route_xy): + dropped += 1 + continue + basins.append({"ring": ring, "props": properties}) + if dropped: + logger.warning( + "B07 유역도: 노선에서 %.0fkm 넘게 떨어진 세부유역 %d개를 뺐습니다 — " + "저장본 좌표계를 되찾지 못했습니다. B04에서 유역을 다시 확정하세요.", + _BASIN_MAX_DISTANCE_M / 1000.0, + dropped, + ) + + # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 + # (2026-08-30 사용자 지시 — 노선이 도엽 경계에 걸릴 수 있어 주변 도엽까지 받아 둔다). + # 읽기·환산·절취는 `map_background()` 한 곳에 있고 계획평면도·용지도도 같은 것을 쓴다. + background = map_background( + Path(context.project_root), + context.crs, + DRAWING_SCALE_BASIN, + map_area_mm(), + [*route_xy, *(point for basin in basins for point in basin["ring"])], + ) + + return { + "route_xy": route_xy, + "basins": basins, + "contours": background["contours"], + "streams": background["streams"], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py new file mode 100644 index 00000000..b4ebb452 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -0,0 +1,88 @@ +"""B07 도면 조립 지원 — 원본 JSON·매니페스트 읽기/쓰기와 측점 대응표. + +지원 모듈이 700줄을 넘어 떼어냈다(2026-09-04). 유역도 조각과 본체가 함께 쓴다. +""" + +import json +import logging +import re +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files + +logger = logging.getLogger(__name__) + + +_STAGE_DIR = "B07_DesignDetail" + +_CROSS_ID = re.compile(r"^cross_(\d+)m$") +_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$") +# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다. +MASS_HAUL_ID = "mass_haul" +WATERSHED_ID = "watershed" +COVER_ID = "cover" + +# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). +# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다. +BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( + ("blank_plan_terrain", "계획평면도(지형)"), + ("blank_plan_route", "계획평면도(노선배치도)"), + ("blank_plan_layout", "계획평면도(배치도)"), + ("blank_plan_lidar", "계획평면도(라이다)"), + ("blank_cross_standard", "표준 횡단면도"), + ("blank_standard", "표준도"), + ("blank_landuse", "용지도"), +) +BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) + + +def _read_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") + return payload + + +def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: + cross_dir = longitudinal_path.parent.parent / "cross_sections" + if not cross_dir.is_dir(): + raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") + stations = longitudinal.get("stations") + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) + files = sorted(cross_dir.glob("cross_*.json")) + if valid_names: + files = [path for path in files if path.name in valid_names] + return files + + +def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: + stations = longitudinal.get("stations", []) + if not isinstance(stations, list): + return {} + return { + round(float(station.get("chainage_m", 0))): station + for station in stations + if isinstance(station, dict) + } + + +def _design_root(project_root: Path) -> Path: + return project_root / _STAGE_DIR + + +def _read_manifest(project_root: Path) -> dict[str, Any]: + path = _design_root(project_root) / "manifest.json" + if not path.is_file(): + return {"drawings": {}} + payload = _read_json(path) + return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} + + +def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: + stage_root = _design_root(project_root) + stage_root.mkdir(parents=True, exist_ok=True) + path = stage_root / "manifest.json" + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index f88fe71f..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): @@ -10,7 +10,18 @@ class DesignDrawingItem(BaseModel): id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] + kind: Literal[ + "cover", + "longitudinal", + "cross", + "mass_haul", + "watershed", + "plan", + "landuse", + "plan_lidar", + "cross_standard", + "blank", + ] label: str chainage_m: float | None = None confirmed: bool = False @@ -33,7 +44,18 @@ class DesignDrawingResponse(BaseModel): route_id: int id: str # blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시). - kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"] + kind: Literal[ + "cover", + "longitudinal", + "cross", + "mass_haul", + "watershed", + "plan", + "landuse", + "plan_lidar", + "cross_standard", + "blank", + ] label: str drawing: dict[str, Any] confirmed: bool = False @@ -80,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 b4458007..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 { @@ -75,8 +54,10 @@ const LABEL_FONT_MM = 2.0; /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ const CHAINAGE_TOLERANCE_M = 0.02; -/** 정의부(해칭 패턴·클립)는 도형이 아니다. 클립된 해칭은 1차 제외(잘라 낼 수단이 없다). */ -const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]"; +/** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */ +const SKIP_SELECTOR = "defs, clipPath, pattern"; +/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */ +const CIRCLE_SEGMENTS = 36; type Entity = Record; type XY = [number, number]; @@ -241,36 +222,171 @@ function parsePoints(element: SVGElement): XY[] { return points; } +/** + * 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null. + * + * 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다. + * 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도 + * 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정). + */ +function clipPolygonOf(element: SVGElement): XY[] | null { + const group = element.closest("g[clip-path]"); + const reference = group?.getAttribute("clip-path") ?? ""; + const id = reference.match(/url\(#([^)]+)\)/)?.[1]; + if (!id) return null; + const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon"); + if (!shape) return null; + const points = parsePoints(shape as unknown as SVGElement); + return points.length >= 3 ? points : null; +} + +/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ +function pointInPolygon(point: XY, polygon: XY[]): boolean { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const straddles = yi > point[1] !== yj > point[1]; + if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */ +function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] { + const [x0, y0] = start; + const [x1, y1] = end; + const dx = x1 - x0; + const dy = y1 - y0; + const cuts = [0, 1]; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [ex, ey] = polygon[j]; + const [fx, fy] = polygon[i]; + const denominator = dx * (fy - ey) - dy * (fx - ex); + if (Math.abs(denominator) < 1e-12) continue; + const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; + const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; + if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); + } + cuts.sort((a, b) => a - b); + const runs: XY[][] = []; + for (let index = 0; index + 1 < cuts.length; index += 1) { + const from = cuts[index]; + const to = cuts[index + 1]; + if (to - from < 1e-9) continue; + const mid = (from + to) / 2; + if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue; + runs.push([ + [x0 + dx * from, y0 + dy * from], + [x0 + dx * to, y0 + dy * to], + ]); + } + return runs; +} + +/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ +function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { + const runs: XY[][] = []; + for (let index = 0; index + 1 < points.length; index += 1) { + runs.push(...clipSegment(points[index], points[index + 1], polygon)); + } + return runs; +} + +function circlePoints(center: XY, radius: number): XY[] { + const points: XY[] = []; + for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) { + const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + +/** + * `rect` → 닫힌 네 모서리 점열(회전 transform 적용). + * + * 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에 + * `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭 + * 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은 + * 무시한다 — 도면에서는 각진 칸으로 충분하다. + */ +function rectPoints(element: SVGElement): XY[] { + const x = attr(element, "x"); + const y = attr(element, "y"); + const width = attr(element, "width"); + const height = attr(element, "height"); + const corners: XY[] = [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + [x, y], + ]; + const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec( + element.getAttribute("transform") ?? "", + ); + if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy)); + const angle = (Number(rotate[1]) * Math.PI) / 180; + const [ox, oy] = [Number(rotate[2]), Number(rotate[3])]; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return corners.map(([cx, cy]) => { + const dx = cx - ox; + const dy = cy - oy; + return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos); + }); +} + /** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { const [fx0, , fx1] = frame; const entities: Entity[] = []; const segments: XY[][] = []; const inside = (px: number): boolean => px >= fx0 && px <= fx1; - const nodes = root.querySelectorAll("polygon, polyline, line, circle, text"); + const nodes = root.querySelectorAll("polygon, polyline, line, circle, rect, text"); for (const element of Array.from(nodes)) { if (element.closest(SKIP_SELECTOR)) continue; const tag = element.tagName.toLowerCase(); - if (tag === "polygon" || tag === "polyline") { - const points = parsePoints(element); + // 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다. + const clip = clipPolygonOf(element); + if (tag === "polygon" || tag === "polyline" || tag === "rect") { + const points = tag === "rect" ? rectPoints(element) : parsePoints(element); if (tag === "polygon" && points.length > 2) points.push(points[0]); - const poly = polyEntity(style, simplify(clipX(points, fx0, fx1))); - if (poly) entities.push(poly); + const runs = clip ? clipPointsToPolygon(points, clip) : [points]; + for (const run of runs) { + const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); + if (poly) entities.push(poly); + } } else if (tag === "line") { const start = flip(attr(element, "x1"), attr(element, "y1")); const end = flip(attr(element, "x2"), attr(element, "y2")); - const cut = clipX([start, end], fx0, fx1); - if (cut.length === 2) segments.push(cut); + for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) { + const cut = clipX(run, fx0, fx1); + if (cut.length === 2) segments.push(cut); + } } else if (tag === "circle") { - const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy")); - if (!inside(cx)) continue; - entities.push( - baseEntity(style, "Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }), - ); + const center = flip(attr(element, "cx"), attr(element, "cy")); + if (!inside(center[0])) continue; + const radius = attr(element, "r"); + if (!clip) { + entities.push( + baseEntity(style, "Circle", { + center: { x: center[0], y: center[1] }, + radius, + }), + ); + continue; + } + // 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다. + for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) { + const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); + if (poly) entities.push(poly); + } } else if (tag === "text") { const label = (element.textContent ?? "").trim(); const at = flip(attr(element, "x"), attr(element, "y")); if (!label || !inside(at[0])) continue; + if (clip && !pointInPolygon(at, clip)) continue; const anchor = element.getAttribute("text-anchor"); const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center"; entities.push(textEntity(style, label, at, align)); @@ -303,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 f7a1fafc..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"; @@ -19,7 +20,7 @@ import { export interface FrameTemplateEditor { /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ button: HTMLButtonElement; - /** 편집 중임을 알리는 CAD 화면 상단 띠 (평소엔 숨김). */ + /** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */ banner: HTMLElement; /** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */ isEditing: () => boolean; @@ -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,12 +72,32 @@ 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", onClick: () => leave(), }); - banner.append(finishButton, resetButton, cancelButton); + const bannerButtons = document.createElement("div"); + bannerButtons.className = "b07-frame-edit__buttons"; + bannerButtons.append(finishButton, importButton, resetButton, cancelButton); + banner.append(bannerButtons, fileInput); const button = createButton({ label: "도각 편집", @@ -81,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 807467e4..3cb61056 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -11,7 +11,6 @@ * ========================================================================== */ import "./B07_DesignDetail_UI_Style.css"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, @@ -21,6 +20,12 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +import { + buildDesignInfoPanel, + buildDrawingSidePanel, + isCrossSheet, +} from "./B07_DesignDetail_UI_Panels"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -30,11 +35,11 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmDesignDrawing, + exportDrawing, fetchDesignDrawing, fetchDesignDrawingList, invalidateDesignDrawing, type CadDrawing, - type CrossDesignInfo, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, @@ -78,216 +83,14 @@ 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"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; -/** - * 도면 구성 12분류 (2026-08-29 사용자 확정 순서). - * - * 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다 - * (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다. - */ -const DRAWING_GROUPS: readonly { - label: string; - kind?: DesignDrawingItem["kind"]; - blankId?: string; -}[] = [ - { label: "표지", kind: "cover" }, - { label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, - { label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, - { label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, - { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, - { label: "종단면도", kind: "longitudinal" }, - { label: "표준 횡단면도", blankId: "blank_cross_standard" }, - { label: "횡단면도", kind: "cross" }, - { label: "토적도(유토곡선)", kind: "mass_haul" }, - { label: "유역도(배수 유역도)", kind: "watershed" }, - { label: "표준도", blankId: "blank_standard" }, - { label: "용지도", blankId: "blank_landuse" }, -]; - -/** B06 확정 산출물 기반 도면 목록 패널. */ -function buildDrawingSidePanel( - drawings: DesignDrawingItem[], - onSelect: (drawing: DesignDrawingItem) => void, - errorMessage?: string, -): HTMLDivElement { - const panel = document.createElement("div"); - panel.className = "b07-drawing-list"; - const heading = document.createElement("div"); - heading.className = "b07-drawing-list__heading"; - const title = document.createElement("strong"); - title.textContent = "설계 도면"; - const count = document.createElement("span"); - count.textContent = `${drawings.length}건`; - heading.append(title, count); - panel.append(heading); - - if (errorMessage || drawings.length === 0) { - const empty = document.createElement("p"); - empty.className = "b07-drawing-list__empty"; - empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다."; - panel.append(empty); - return panel; - } - - const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b07-drawing-button"; - button.dataset.drawingId = drawing.id; - button.dataset.confirmed = String(drawing.confirmed); - const name = document.createElement("span"); - name.className = "b07-drawing-button__name"; - name.textContent = label; - button.append(name); - button.addEventListener("click", () => onSelect(drawing)); - return button; - }; - - for (const group of DRAWING_GROUPS) { - const items = group.kind - ? drawings.filter((item) => item.kind === group.kind) - : drawings.filter((item) => item.id === group.blankId); - // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. - if (items.length <= 1) { - const [drawing] = items; - // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. - if (!drawing) { - const placeholder = document.createElement("button"); - placeholder.type = "button"; - placeholder.className = "b07-drawing-button"; - placeholder.disabled = true; - placeholder.title = "준비 중"; - placeholder.dataset.pending = "true"; - const name = document.createElement("span"); - name.className = "b07-drawing-button__name"; - name.textContent = group.label; - placeholder.append(name); - panel.append(placeholder); - continue; - } - const button = drawingButton(drawing, group.label); - // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. - button.dataset.pending = String(drawing.kind === "blank"); - if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; - panel.append(button); - continue; - } - const section = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). - section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; - if (group.kind) section.dataset.kind = group.kind; - const sectionTitle = document.createElement("h3"); - sectionTitle.className = "ui-collapsible__title"; - sectionTitle.textContent = `${group.label} ${items.length}`; - section.append(sectionTitle); - for (const drawing of items) { - // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. - section.append(drawingButton(drawing, drawing.label)); - } - panel.append(section); - } - attachCollapsible(panel); - return panel; -} - -const GROUND_TYPE_LABEL: Record = { - soil: "B06_Design_Ground_Soil", - ripping_rock: "B06_Design_Ground_Ripping", - blasting_rock: "B06_Design_Ground_Blasting", -}; - -/** 단면유형에서 절토측 표기를 유도한다. */ -function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { - if (mode === "left_cut") return L("B06_Design_Ditch_Left"); - if (mode === "right_cut") return L("B06_Design_Ditch_Right"); - if (mode === "both_cut") return L("B06_Design_Mode_BothCut"); - return L("B06_Design_Mode_BothFill"); -} - -/** 측구 규격 표시 문자열 (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.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`; -} - -function infoRow(label: string, value: string): HTMLElement { - const row = document.createElement("div"); - row.className = "b07-info__row"; - const key = document.createElement("span"); - key.className = "b07-info__key"; - key.textContent = label; - const val = document.createElement("span"); - val.className = "b07-info__val"; - val.textContent = value; - row.append(key, val); - return row; -} - -/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */ -function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement { - const panel = document.createElement("div"); - panel.className = "b07-info"; - const heading = document.createElement("div"); - heading.className = "b07-info__heading"; - const stationName = document.createElement("strong"); - stationName.textContent = `${L("B07_Info_Station")} ${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"); - heading.append(stationName, badge); - panel.append(heading); - - if (!design) { - const empty = document.createElement("p"); - empty.className = "b07-info__empty"; - empty.textContent = L("B07_Info_NoDesign"); - panel.append(empty); - return panel; - } - - const ground = document.createElement("section"); - ground.className = "b07-info__block"; - const groundTitle = document.createElement("h4"); - groundTitle.textContent = L("B07_Info_Ground_Title"); - ground.append( - groundTitle, - infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])), - 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"), - ), - ); - - const plan = document.createElement("section"); - plan.className = "b07-info__block"; - const planTitle = document.createElement("h4"); - 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_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`), - infoRow(L("B07_Info_Ditch"), ditchLabel(design)), - infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), - infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), - ); - - panel.append(ground, plan); - return panel; -} - /* ----------------------------------------------------------------------------- - * 페이지 진입점 + * 화면 조립 * -------------------------------------------------------------------------- */ export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); @@ -323,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; @@ -341,8 +151,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { infoPanelHost.replaceChildren(); return; } - const title = drawing.label; - infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null)); + // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 + // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. + const scope = isCrossSheet(drawing) ? "sheet" : "station"; + infoPanelHost.replaceChildren( + buildDesignInfoPanel(drawing.label, response.design ?? null, scope), + ); }; /** @@ -400,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; @@ -544,7 +365,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. if (currentDrawing.kind === "cross") { infoPanelHost.replaceChildren( - buildDesignInfoPanel(currentDrawing.label, result.design ?? null), + buildDesignInfoPanel( + currentDrawing.label, + result.design ?? null, + isCrossSheet(currentDrawing) ? "sheet" : "station", + ), ); } showToast("현재 도면을 확정하고 저장했습니다.", "success"); @@ -592,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, @@ -600,8 +453,9 @@ 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, }); - cadHost.prepend(frameEditor.banner); window.addEventListener("message", (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return; @@ -611,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; @@ -638,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) { @@ -652,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; @@ -668,7 +531,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). confirmActions.className = "b07-drawing-actions ui-sidebar-actions"; - confirmActions.append(frameEditor.button, confirmButton); + // 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]·[현재 도면 확정] + // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김). + const confirmButtonRow = document.createElement("div"); + confirmButtonRow.className = "b07-drawing-actions__row"; + confirmButtonRow.append(frameEditor.button, confirmButton); + confirmActions.append(frameEditor.banner, confirmButtonRow); drawingPanel.append(infoPanelHost, confirmActions); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts new file mode 100644 index 00000000..05a5d1fd --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -0,0 +1,240 @@ +/* ============================================================================= + * B07_DesignDetail_UI_Panels.ts + * 상세 설계 화면의 좌측 패널 — 도면 목록 12분류, 측점 설계 정보 카드. + * + * 화면 조립(`B07_DesignDetail_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다. + * ========================================================================== */ + +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"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** + * 도면 구성 12분류 (2026-08-29 사용자 확정 순서). + * + * 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다 + * (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다. + */ +export const DRAWING_GROUPS: readonly { + label: string; + kind?: DesignDrawingItem["kind"]; + blankId?: string; + /** 축척 고정으로 장이 나뉘는 도면 — `plan_route`, `plan_route_2` … 를 한 묶음으로 본다. */ + idPrefix?: string; +}[] = [ + { label: "표지", kind: "cover" }, + { label: "계획평면도(지형)", idPrefix: "plan_terrain" }, + { label: "계획평면도(노선배치도)", idPrefix: "plan_route" }, + { label: "계획평면도(배치도)", idPrefix: "plan_layout" }, + { label: "계획평면도(라이다)", idPrefix: "plan_lidar" }, + { label: "종단면도", kind: "longitudinal" }, + { label: "표준 횡단면도", kind: "cross_standard" }, + { label: "횡단면도", kind: "cross" }, + { label: "토적도(유토곡선)", kind: "mass_haul" }, + { label: "유역도(배수 유역도)", kind: "watershed" }, + { label: "표준도", blankId: "blank_standard" }, + { label: "용지도", idPrefix: "landuse" }, +]; + +/** B06 확정 산출물 기반 도면 목록 패널. */ +export function buildDrawingSidePanel( + drawings: DesignDrawingItem[], + onSelect: (drawing: DesignDrawingItem) => void, + errorMessage?: string, +): HTMLDivElement { + const panel = document.createElement("div"); + panel.className = "b07-drawing-list"; + const heading = document.createElement("div"); + heading.className = "b07-drawing-list__heading"; + const title = document.createElement("strong"); + title.textContent = "설계 도면"; + const count = document.createElement("span"); + count.textContent = `${drawings.length}건`; + heading.append(title, count); + panel.append(heading); + + if (errorMessage || drawings.length === 0) { + const empty = document.createElement("p"); + empty.className = "b07-drawing-list__empty"; + empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다."; + panel.append(empty); + return panel; + } + + const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b07-drawing-button"; + button.dataset.drawingId = drawing.id; + button.dataset.confirmed = String(drawing.confirmed); + const name = document.createElement("span"); + name.className = "b07-drawing-button__name"; + name.textContent = label; + button.append(name); + button.addEventListener("click", () => onSelect(drawing)); + return button; + }; + + for (const group of DRAWING_GROUPS) { + const items = group.kind + ? drawings.filter((item) => item.kind === group.kind) + : group.idPrefix + ? drawings.filter( + (item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`), + ) + : drawings.filter((item) => item.id === group.blankId); + // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. + if (items.length <= 1) { + const [drawing] = items; + // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. + if (!drawing) { + const placeholder = document.createElement("button"); + placeholder.type = "button"; + placeholder.className = "b07-drawing-button"; + placeholder.disabled = true; + placeholder.title = "준비 중"; + placeholder.dataset.pending = "true"; + const name = document.createElement("span"); + name.className = "b07-drawing-button__name"; + name.textContent = group.label; + placeholder.append(name); + panel.append(placeholder); + continue; + } + const button = drawingButton(drawing, group.label); + // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. + button.dataset.pending = String(drawing.kind === "blank"); + if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; + panel.append(button); + continue; + } + const section = document.createElement("section"); + // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). + section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; + if (group.kind) section.dataset.kind = group.kind; + const sectionTitle = document.createElement("h3"); + sectionTitle.className = "ui-collapsible__title"; + sectionTitle.textContent = `${group.label} ${items.length}`; + section.append(sectionTitle); + for (const drawing of items) { + // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. + section.append(drawingButton(drawing, drawing.label)); + } + panel.append(section); + } + attachCollapsible(panel); + return panel; +} + +const GROUND_TYPE_LABEL: Record = { + soil: "B06_Design_Ground_Soil", + ripping_rock: "B06_Design_Ground_Ripping", + blasting_rock: "B06_Design_Ground_Blasting", +}; + +/** 단면유형에서 절토측 표기를 유도한다. */ +function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { + if (mode === "left_cut") return L("B06_Design_Ditch_Left"); + if (mode === "right_cut") return L("B06_Design_Ditch_Right"); + if (mode === "both_cut") return L("B06_Design_Mode_BothCut"); + return L("B06_Design_Mode_BothFill"); +} + +/** 장(여러 측점을 담은 횡단 도면)인가 — id 가 `cross_s…` 면 장이다. */ +export function isCrossSheet(drawing: DesignDrawingItem): boolean { + return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); +} + +/** 측구 규격 표시 문자열 (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.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`; +} + +function infoRow(label: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "b07-info__row"; + const key = document.createElement("span"); + key.className = "b07-info__key"; + key.textContent = label; + const val = document.createElement("span"); + val.className = "b07-info__val"; + val.textContent = value; + row.append(key, val); + return row; +} + +/** + * 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). + * + * **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지 + * 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리). + * 장이면 제목을 「장」으로 바꾸고 어디서 봐야 하는지 한 줄로 알린다. + */ +export function buildDesignInfoPanel( + title: string, + design: CrossDesignInfo | null, + scope: "station" | "sheet" = "station", +): HTMLElement { + const panel = document.createElement("div"); + panel.className = "b07-info"; + 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"); + 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"); + 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"); + panel.append(empty); + return panel; + } + + const ground = document.createElement("section"); + ground.className = "b07-info__block"; + const groundTitle = document.createElement("h4"); + groundTitle.textContent = L("B07_Info_Ground_Title"); + ground.append( + groundTitle, + infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])), + 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"), + ), + ); + + const plan = document.createElement("section"); + plan.className = "b07-info__block"; + const planTitle = document.createElement("h4"); + 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_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`), + infoRow(L("B07_Info_Ditch"), ditchLabel(design)), + infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), + infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), + ); + + panel.append(ground, plan); + return panel; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index f2ca6ee6..1dcfce89 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -84,6 +84,9 @@ align-items: center; justify-content: center; width: 100%; + /* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가 + 오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */ + min-width: 0; min-height: 34px; padding: var(--spacing-4) var(--spacing-8); /* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */ @@ -126,11 +129,25 @@ /* 하단 고정·배경·상단 구분선은 공용 ui-sidebar-actions가 맡는다(2026-08-18 통합). 여기서는 B07 고유 여백만 남긴다. */ +/* 액션 칸은 세로 두 줄 — 1행 도각 편집 띠, 2행 [도각 편집]·[현재 도면 확정]. + 공용 .ui-sidebar-actions는 가로 한 줄이라 방향과 늘어남을 여기서 되돌린다. */ .b07-drawing-actions { + flex-direction: column; padding-top: var(--spacing-12); } -.b07-drawing-actions > button { +.b07-drawing-actions > * { + flex: none; +} + +.b07-drawing-actions__row { + display: flex; + gap: var(--spacing-8); +} + +.b07-drawing-actions__row > button { + flex: 1 1 0; + min-width: 0; width: 100%; } @@ -271,29 +288,36 @@ color: var(--color-text-muted); } -/* 도각 편집 모드 띠 — CAD 위에 겹쳐 편집 중임을 알리고 [완료]·[취소]를 준다. */ +/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과 + 겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */ .b07-frame-edit { - position: absolute; - z-index: 3; - top: 8px; - left: 50%; - transform: translateX(-50%); display: flex; - align-items: center; + flex-direction: column; gap: var(--spacing-8); - padding: 6px 10px; + padding: var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-cards); background-color: var(--color-surface); - box-shadow: 0 6px 18px rgb(0 0 0 / 25%); +} + +.b07-frame-edit[hidden] { + display: none; } .b07-frame-edit__label { font-size: 0.78rem; + line-height: 1.4; color: var(--color-text); } -.b07-frame-edit > button { - padding: 4px 12px; +.b07-frame-edit__buttons { + display: flex; + gap: var(--spacing-8); +} + +.b07-frame-edit__buttons > button { + flex: 1 1 0; + min-width: 0; + padding: 4px 8px; font-size: 0.78rem; } diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 6d9438ae..9d3fb9cb 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -4,7 +4,9 @@ :root { --cad-title-height: 42px; - --cad-ribbon-height: 116px; + /* 116px 에서는 리본 줄의 내용(85px)이 보이는 높이(81px)를 넘어 세로 스크롤바가 + 생겼다(2026-09-04 실측) — 모자란 4px + 여유 2px 를 더한다. */ + --cad-ribbon-height: 122px; /* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */ --cad-command-height: 86px; --cad-status-height: 28px; @@ -940,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/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 6f1fd7ad..0c21cede 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -1,4 +1,5 @@ /** 뷰 탭 — 탐색·재생성 명령 (조사표 7절 중 구현분) */ +import { Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; import type { CadCommand } from './command.types'; import { bumpSceneVersion } from '../helpers/scene-version'; @@ -8,8 +9,23 @@ import { selectToolStateMachine } from '../tools/select-tool'; const zoomBy = (factor: number): string => { const controller = getScreenCanvasDrawController(); - controller.setScreenScale(Math.max(0.01, controller.getScreenScale() * factor)); - return `줌 ${Math.round(controller.getScreenScale() * 100)}%`; + const oldScale = controller.getScreenScale(); + const newScale = Math.max(0.01, oldScale * factor); + // 배율만 바꾸면 화면이 월드 원점 쪽으로 늘어나 도면이 화면 밖으로 밀려난다. + // 휠 줌이 커서 밑 좌표를 붙잡아 두듯, 버튼 줌은 **화면 중심**의 월드 좌표를 + // 붙잡아 둔다 (screen = (world - offset) * scale). + const canvasSize = controller.getCanvasSize(); + const offset = controller.getScreenOffset(); + const worldCenterX = offset.x + canvasSize.x / 2 / oldScale; + const worldCenterY = offset.y + canvasSize.y / 2 / oldScale; + controller.setScreenScale(newScale); + controller.setScreenOffset( + new Point( + worldCenterX - canvasSize.x / 2 / newScale, + worldCenterY - canvasSize.y / 2 / newScale + ) + ); + return `줌 ${Math.round(newScale * 100)}%`; }; export const VIEW_COMMANDS: CadCommand[] = [ 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/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 653ce154..c45c847c 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -19,7 +19,7 @@ import { ViewControls } from './ViewControls'; export const Toolbar: FC = () => { useCadRefresh(); const [panelCollapsed, setPanelCollapsed] = useState(false); - const [commandLineVisible, setCommandLineVisible] = useState(true); + const [commandLineVisible, setCommandLineVisible] = useState(false); const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null; diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 4facbdbd..2afd797f 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -596,6 +596,11 @@ export class ScreenCanvasDrawController implements DrawController { angle: number ): void { if (this.batching) this.flushBatch(); + // 못 읽은 그림은 건너뛴다. 'broken' 상태의 그림을 그리려 하면 캔버스가 예외를 + // 던지고, 그 예외가 렌더 루프를 끊어 **이후 모든 도면이 백지**로 남았다 + // (2026-09-02 실측: 도각 자리표시자가 404 로 깨진 채 줌 한 번에 화면이 멈췄다). + // 자리 하나가 비는 것과 도면 전체가 안 나오는 것은 무게가 다르다. + if (imageElement.complete === false || imageElement.naturalWidth === 0) return; // 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과 // y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02 // 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다. diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index 3476aa8b..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'; @@ -26,6 +26,29 @@ export class ImageEntity implements Entity { private imageElement: HTMLImageElement; private polygon: Polygon; private angle: number; + /** + * JSON에서 받은 그림 주소 원본. `imageElement.currentSrc`는 브라우저가 절대 URL로 + * 바꿔 놓아, 도각의 자리표시자(`{{회사로고}}`)가 저장 한 번에 + * `http://…/b07-cad/%7B%7B회사로고%7D%7D`로 굳는다(2026-09-02 실측 — 회사 도각이 + * 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다. + */ + 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, @@ -62,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)); } @@ -115,7 +179,9 @@ export class ImageEntity implements Entity { public clone(): ImageEntity { const clonedImage = document.createElement('img'); clonedImage.src = this.imageElement.src; - return new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone()); + const cloned = new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone()); + cloned.sourceData = this.sourceData; + return cloned; } // TODO add destroy method to cleanup this.imageElement.src @@ -230,7 +296,7 @@ export class ImageEntity implements Entity { x: vertex.x, y: vertex.y, })), - imageData: this.imageElement.currentSrc, + imageData: this.sourceData ?? this.imageElement.currentSrc, }, }; } @@ -243,7 +309,12 @@ export class ImageEntity implements Entity { jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y)) ); const image = new Image(); - image.src = jsonEntity.shapeData.imageData; + // 자리표시자는 주소가 아니다 — 그대로 넣으면 404 요청이 나가고 그림이 'broken' + // 상태가 된다. 그 상태의 그림을 그리려 하면 캔버스가 예외를 던져 렌더 루프가 + // 끊기고 **이후 모든 도면이 백지**로 남았다(2026-09-02 실측). 자리만 남긴다. + if (!jsonEntity.shapeData.imageData.includes('{{')) { + image.src = jsonEntity.shapeData.imageData; + } const rectangleEntity = new ImageEntity( jsonEntity.layerId || getActiveLayerId(), image, @@ -253,6 +324,7 @@ export class ImageEntity implements Entity { rectangleEntity.lineColor = jsonEntity.lineColor; rectangleEntity.lineWidth = jsonEntity.lineWidth; rectangleEntity.lineDash = jsonEntity.lineDash; + rectangleEntity.sourceData = jsonEntity.shapeData.imageData; return rectangleEntity; } } 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/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py new file mode 100644 index 00000000..ba5cb8d7 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -0,0 +1,743 @@ +"""산림사업 표준품셈 476표 → 공종 마스터 정규화 (B08 일감 1번 · 공종 축). + +무엇을 만드나 + `resources/data_work_item_master/work_item_master_.json` — 공종 계층 + 표 귀속. + 같은 폴더에 `form_undetermined_*.json`(형태 판정 실패분)과 `_manifest.json` 을 함께 낸다. + +왜 이렇게 나누나 (PLAN 8-7 담당 경계) + 이 파일은 **공종 축만** 만든다. 자원 축(`resource_kind`·`resource_code`·`amount`)은 + 단가표를 아는 B09 가 뒤 패스로 채운다. 그래서 각 표의 **원문 셀(`raw_row`)을 그대로 실어 + 보낸다** — 버리면 B09 가 476표를 다시 열어야 한다. + +공종의 정체 = 품셈의 **절 번호**다 + 품셈은 「9-3-1. 인력」처럼 절 제목이 공종이고, 표의 행은 그 공종의 **조건별 변형** + (토질·암종·규격)이다. 그래서 계층·정렬은 목차표(`F0001`)에서 나오고, 표는 그 절에 붙는다. + +⚠ 최대 함정 — `pum_form` (PLAN 8-6) + 같은 숫자라도 뜻이 반대다. + productivity(생산량형) : 「㎥/hr」·「㎥/1인/1일」 → 품 = 1 ÷ 값 + requirement(소요량형) : 「100㎥당 x인」 → 품 = 값 ÷ 밑수 + 태그가 없으면 뒤집힌 값이 조용히 들어간다. **판정 못 한 표는 빈칸으로 두지 않고 + `form_undetermined` 목록으로 뽑아** 사람이 보게 한다. + +실행 + ./venv/Scripts/python.exe B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json" +OUT_DIR = ROOT / "resources" / "data_work_item_master" + +SCHEMA_VERSION = "1.0" +CODE_PREFIX = "FP" # Forest Pumsem — 공종코드 접두. 품셈 절 번호를 그대로 싣는다. + +# ── 형태 판정 ──────────────────────────────────────────────────────────── +# 헤더·비고 문자열에서 찾는 표지. 앞의 것이 먼저 걸린다(생산량형이 더 좁은 표현이라 우선). +PRODUCTIVITY_MARKS = ( + "㎥/hr", + "m3/hr", + "㎡/hr", + "본/hr", + "/1인/1일", + "/인/1일", + "인/1일", + "작업능력", + "ha당 평균 작업량", + "ha당 집재재적", +) +REQUIREMENT_MARKS = ( + "소요인력", + "소요량", + "당 주입량", + "단위수량", + "수 량", + "수량", + "인/km", + "인/ha", + "㏊당", + "ha당", + "당 소요", +) +# 값이 공종 품이 아니라 계산식 파라미터인 표. 공종으로 세우지 않는다. +COEFFICIENT_MARKS = ("손료계수", "시간당손료계수", "기계손료") +# 기계 시공능력 공식(Q = 3600·qo·K·f·E / Cm)의 파라미터 기호. 이 기호만 있는 표는 계수표다. +COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L"} +# 품셈 1장(적용기준)·할증·공제·단위 표준 — 공종이 아니라 **기준표**다. +# ⚠ **「단 위」를 뺐다** (2026-09-07). 원래 겨냥은 품셈 1-2-2 「단위 표준」 표였는데, +# **1장은 chapter 규칙이 이미 참조로 거르므로** 이 표지는 넓기만 했다. 그 탓에 +# **41건이 「헤더 '단 위'」 하나로 참조가 되어 버려지고 있었고**, 그 안에 +# **돌쌓기(장비) 13-4-5·13-4-2**(우리 매핑이 실제로 쓰는 코드) · 12-2 표면 마무리 · +# 9-19-1 면고르기 · 9-20 뿌리다듬기 · 13-8 막돌쌓기 같은 **명백한 공종**이 섞여 있었다. +# 「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다. +REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위") +# 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다. +REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용") +# 품셈이 실어 둔 **빈 단가산출서 서식** — 값이 없는 예시 양식이다(2026-09-08 ㉙, 서브가 찾음). +# 첫 줄이 「위치 및 임·소반」·「위치 및 면적」이고 다음 줄이 「구분 | 작업량 | 단위품 | +# 소요품 | 단가」인 모양. 채워 넣으라고 둔 칸이라 **자원도 값도 없다.** +# ⚠ 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다 — 애초에 값이 없다. +# 서브가 그 표들 탓에 못 맞춘 자원이 882 → 497 로 부풀어 있었다. +# ⚠ **두 조건을 다 만족할 때만** 본다 — 「구분」·「단가」는 정상 표에도 흔한 낱말이라 +# 하나만 보면 정상 표를 지운다(오늘 그 병을 열한 번 겪었다). +#: 표지는 **머리글에 그대로 적혀 있다** — 「ha당 참나무시들음병방제 **단가산출서(예시)**」. +#: 낱말 하나로 충분히 좁다(정상 표 머리글에 「단가산출서」가 올 일이 없다). +BLANK_FORM_MARK = "단가산출서" +#: 머리글이 비어 있는 판을 대비한 보조 표지 — **두 조건을 다 만족할 때만** 본다. +BLANK_FORM_HEAD_MARKS = ("위치및임소반", "위치및면적") +BLANK_FORM_BODY_MARKS = ("단위품", "소요품") +# 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다. +# ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`squeeze`). +OCCUPATION_RE = re.compile( + r"\((?:인|조|인/일|인·일)\)|인부|기능공|운전사|특별인부|보통인부|콘크리트공|철근공|石工|석공|목공|용접공" +) +# 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다. +MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)") + +# ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양). +# `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다. +# 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도 +# 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다. +CLASSIFICATION_TAGS = frozenset( + { + "자재", + "재료", + "재료비", + "자재비", + "잡재료", + "장비", + "기계", + "인력", + "노무", + "노무비", + "인건비", + "설치비", + "경비", + "공구손료", + } +) +# 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다. +RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"}) +# 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다. +RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준") + + +def sha256_of(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def norm(text: Any) -> str: + """공백을 하나로 줄인 문자열. `None` 은 빈 문자열.""" + return " ".join(str(text).split()) if text is not None else "" + + +def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]: + """목차표(F0001) → 계층 목록. 장(제n장)·절(n-n)·항(n-n-n)·목(n-n-n-n).""" + nodes: list[dict[str, Any]] = [] + order = 0 + for raw in rows: + cells = [norm(c) for c in raw] + cells = [c for c in cells if c] + if not cells: + continue + key = cells[0] + name = cells[1] if len(cells) > 1 else "" + if m := re.fullmatch(r"제(\d+)장", key): + number = m.group(1) + elif re.fullmatch(r"\d+(?:-\d+){1,3}", key): + number = key + else: + continue # 부록 등 — 공종 계층이 아니다. + order += 256 # STmate 의 SORTCODE 관례(256 간격) — 중간 삽입 여유. + parts = number.split("-") + nodes.append( + { + "work_item_code": f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts), + "number": number, + "name": name, + "level": len(parts), + "parent_code": ( + f"{CODE_PREFIX}-" + "-".join(p.zfill(2) for p in parts[:-1]) + if len(parts) > 1 + else None + ), + "sort_order": order, + "tables": [], + } + ) + return nodes + + +def section_number(section: str) -> str | None: + """`"9-3-1. 인력"` → `"9-3-1"`. 번호가 없으면 `None`.""" + m = re.match(r"^\s*(\d+(?:-\d+){0,3})[.\s]", section + " ") + return m.group(1) if m else None + + +def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]: + """`(pum_form, 근거 문구)`. 판정 못 하면 `("undetermined", 이유)`. + + 순서가 뜻을 가진다 — 좁은 표지부터 본다. 계수·기준표를 먼저 걸러 내야 + 「작업능력」 같은 흔한 낱말이 기준표를 공종으로 오인하지 않는다. + """ + hay = " ".join(norm(h) for h in table.get("headers", [])) + keys = [norm(r[0]) for r in table.get("rows", []) if r] + head_rows = [norm(c) for r in table.get("rows", [])[:3] for c in r] + first_rows = " ".join(head_rows) + both = f"{hay} || {first_rows}" + squeezed = both.replace(" ", "") # `인 부` → `인부`. 원문 자간 공백을 지운 뒤 비교한다. + + for mark in COEFFICIENT_MARKS: + if mark in hay: + return "coefficient", f"헤더 '{mark}'" + if keys and set(keys) <= COEFFICIENT_ROW_KEYS: + return "coefficient", f"행 키가 시공능력 공식 기호뿐 {sorted(set(keys))}" + if keys and all(re.fullmatch(r"[a-zA-Zqf][₀-₉0-9]?", k) for k in keys): + return "coefficient", f"행 키가 기호뿐 {sorted(set(keys))}" + + # 1장은 적용기준 장 자체다 — 공종이 아니라 기준표로 둔다(PLAN 8-14 「목록은 규정에서」). + if chapter == "1": + return "reference", "품셈 제1장(적용기준)" + for mark in REFERENCE_MARKS: + if mark in hay: + return "reference", f"헤더 '{mark}'" + for mark in REFERENCE_CELL_MARKS: + if mark in squeezed: + return "reference", f"'{mark}' — 값이 아니라 참조 지시" + + # 빈 단가산출서 서식 — 머리글 표지가 정본, 머리 두 줄은 보조. + if BLANK_FORM_MARK in hay.replace(" ", ""): + return "reference", f"'{BLANK_FORM_MARK}' — 채워 넣으라고 둔 빈 서식" + head2 = "".join( + norm(str(c)) for row in (table.get("rows") or table.get("raw_row") or [])[:2] for c in row + ) + for mark in ("․", "·", "ㆍ", " "): + head2 = head2.replace(mark, "") + if any(m in head2 for m in BLANK_FORM_HEAD_MARKS) and any( + m in head2 for m in BLANK_FORM_BODY_MARKS + ): + return "reference", "품셈이 실어 둔 빈 단가산출서 서식 — 채워 넣으라고 둔 칸" + + # ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/hr)」 표의 비고란에 흔히 + # 「보통인부 1인/일」이 붙어 있어, 직종을 먼저 보면 생산량형이 소요량형으로 뒤집힌다. + # 그 뒤집힘이 곧 PLAN 8-6 이 경고한 「값이 조용히 반대로 들어가는」 사고다. + for mark in PRODUCTIVITY_MARKS: + if mark in hay: + return "productivity", f"헤더 '{mark}'" + + # 직종이 값의 주인이면 소요량형이다 — 「보통인부(인) 0.16」 은 ㎥당 품이다. + if OCCUPATION_RE.search(squeezed): + return "requirement", "직종 표기((인)·인부·공)" + for mark in MATERIAL_UNIT_MARKS: + if mark in hay: + return "requirement", f"값 단위 '{mark}'" + + for mark in PRODUCTIVITY_MARKS: + if mark in both: + return "productivity", f"본문 '{mark}'" + for mark in REQUIREMENT_MARKS: + if mark in both: + return "requirement", f"'{mark}'" + + # ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다. + tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row] + if any(tag in CLASSIFICATION_TAGS for tag in tags): + # ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에 + # 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다** + # — 만들다 실제로 걸린 자리다. + # 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다. + has_number = any( + re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r + ) + if has_number and any(tag in RESOURCE_TAGS for tag in tags): + return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}" + body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "") + for mark in RATIO_DIRECTIVE_MARKS: + if mark in body: + return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시" + + return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음" + + +BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개|kg|㎏|톤|ton)\s*당") + +# ⚠ **밑수는 표 안이 아니라 표 바로 위 본문에 있다** (2026-09-07 서브 창 제보로 파고 확인). +# `### 12-2. 표면 마무리` 다음 줄에 `(단위: ㎡당)` 이 오는 식이다. 표만 보면 못 찾고, +# 못 찾은 채로 두면 「10㎡당」 표를 1㎡당으로 알아 **곱셈이 10배 틀린다**. +# 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다. +# ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 — +# 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다. +#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다. +SOURCE_BASIS_RATIO_RE = re.compile( + r"[((]\s*단위\s*[::]\s*[^))/]+/\s*([\d,]*\.?\d*)\s*" + r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[))]" +) + +SOURCE_BASIS_RE = re.compile( + r"[((]\s*(?:" + r"단위\s*[::]\s*([\d,]*\.?\d*)\s*(?P㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당?" + r"|([\d,]*\.?\d*)\s*(?P㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당" + r")\s*[))]" +) +#: 표 위로 몇 줄까지 거슬러 볼 것인가. 더 올라가면 **앞 표의 밑수**를 잘못 물어 온다. +SOURCE_LOOKBACK = 6 + +#: 표 **아래 [주]** 에만 밑수가 적힌 자리 — 「목재의 손율은 **1개소 사용당** 50%로 한다」. +#: ⚠ **아주 좁게 잡는다.** 「N개소 **사용** 당」이라는 표기는 품셈 전문에 **딱 두 곳**뿐이고 +#: (11-2 비탈 규준틀 · 11-3 수평 규준틀) 둘 다 개소로 세는 표다. 「개소당 평균면적」 같은 +#: 흔한 말은 「사용」이 없어 안 걸린다 — 넓히면 조림 2장이 통째로 걸린다(실측 19건). +#: ⚠ 뜻으로도 개소가 맞다 — [주]② 가 「본 품은 …한 **비탈규준틀**의 제작·도색·가설·철거를 +#: 포함한 것」이라 **규준틀 하나**를 말하고, [주]① 이 「20m마다 **설치**」라 센다. +SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당") +#: 표 아래로 몇 줄까지 볼 것인가. [주] 는 표 바로 밑에 붙는다. +SOURCE_NOTE_LOOKAHEAD = 8 + + +def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]: + """표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다. + + ⚠ 위로 거슬러 보되 **다른 표를 만나면 멈춘다**. 앞 표의 밑수를 물어 오면 조용히 틀린다. + """ + start = max(0, line_no - 1 - SOURCE_LOOKBACK) + for index in range(line_no - 2, start - 1, -1): + if index < 0 or index >= len(lines): + continue + text = lines[index].strip() + if text.startswith("|"): + break # 앞 표에 닿았다 — 그 위는 남의 밑수다 + if m := SOURCE_BASIS_RATIO_RE.search(text): + raw = (m.group(1) or "").replace(",", "") + try: + quantity = float(raw) if raw else 1.0 + except ValueError: + quantity = 1.0 + return quantity, m.group(2) + if m := SOURCE_BASIS_RE.search(text): + unit = m.group("u1") or m.group("u2") + raw = (m.group(1) if m.group("u1") else m.group(3)) or "" + raw = raw.replace(",", "") + try: + quantity = float(raw) if raw else 1.0 + except ValueError: + quantity = 1.0 + return quantity, unit + + # 표 **아래 [주]** 도 본다 — 위에 아무것도 없을 때만. 규준틀 둘이 그 자리다. + # ⚠ 다음 표(`|`)나 다음 절(`###`)을 만나면 멈춘다 — 남의 [주]를 물어 오면 조용히 틀린다. + for index in range(line_no, min(len(lines), line_no + SOURCE_NOTE_LOOKAHEAD)): + text = lines[index].strip() + if text.startswith("###"): + break + if m := SOURCE_BASIS_NOTE_RE.search(text): + raw = (m.group(1) or "").replace(",", "") + try: + quantity = float(raw) if raw else 1.0 + except ValueError: + quantity = 1.0 + return quantity, "개소" + return None, None + + +def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]: + """「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.""" + hay = " ".join(norm(h) for h in table.get("headers", [])) + hay += " " + " ".join(norm(c) for r in table.get("rows", [])[:2] for c in r) + if m := BASIS_RE.search(hay): + try: + return float(m.group(1).replace(",", "")), m.group(2) + except ValueError: + return None, m.group(2) + return None, None + + +# ⚠ **딱지가 비율을 달고 오는 표** — `인력(10%)` · `장비(90%)` (2026-09-07 서브 창 제보로 확인). +# 그 표는 소요량형이면서 **장비 몫이 시공능력 공식**(Q = 3600·q·K·f·E ÷ Cm)이라 +# **인력 10 % 만 값으로 서 있다.** 형태 한 낱말(`requirement`)로만 적으면 받는 쪽이 +# 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. 그래서 **몫과 미완 여부를 따로 싣는다.** +SHARE_TAG_RE = re.compile( + r"^(자재|재료|재료비|자재비|잡재료|장비|기계|인력|노무|노무비|인건비|경비|공구손료)" + r"\s*[((]\s*(\d+(?:\.\d+)?)\s*[%%]\s*[))]$" +) +#: 시공능력 공식 파라미터. 이 기호가 있으면 그 몫은 **아직 조립이 안 된 것**이다. +CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"} + + +# ⚠ **값 자리에 숫자가 아니라 식이 적힌 칸** (2026-09-07 서브 창 제보로 확인). +# `0.2 × 30%`(기초잡석 소할) · `(0.2+0.26)/2` · `1/1.3` 처럼 계산이 그대로 적혀 있다. +# 형태 판정은 통과하는데 **값이 숫자로 안 읽혀 그 성분이 조용히 빠진다** — +# `partial_ratio` 와 같은 병인데 배분율 딱지가 없어 아무 검사에도 안 걸렸다. +# ⚠ 여기서 **식을 계산하지 않는다.** 뜻을 잘못 읽으면 조용히 틀리므로 **드러내기만** 한다. +_PLAIN_NUMBER_RE = re.compile(r"^[\d,]+(?:\.\d+)?$") +_CALC_CELL_RE = re.compile(r"^[\d,.\s()×xX*/÷+\-%]+$") +_CALC_MAX_LEN = 22 + + +def expression_cells(table: dict[str, Any]) -> list[str]: + """값 자리에 식이 적힌 칸 목록. 없으면 빈 목록.""" + found: list[str] = [] + for row in table.get("rows", []): + for cell in row: + text = norm(cell) + if not text or len(text) > _CALC_MAX_LEN or _PLAIN_NUMBER_RE.match(text): + continue + if ( + _CALC_CELL_RE.match(text) + and re.search(r"\d", text) + and re.search(r"[×xX*/÷+]", text) + ): + found.append(text) + # 순서를 지키되 중복은 지운다 — 같은 식이 여러 줄에 반복된다. + seen: list[str] = [] + for item in found: + if item not in seen: + seen.append(item) + return seen + + +# ⚠ **작업조 표** — 「형틀목공 4인 + 보통인부 1인 / 시공량 25·35·40㎡」처럼 +# **인원과 시공량**으로 적힌 표다. 그 「4」는 소요량이 아니라 **작업조 인원**이라 +# 행-자원으로 그냥 읽으면 **35배 부푼다**(유로폼 12-38-3 이 그 표다). +# ⚠ 값을 바꾸지 않고 **표시만** 한다 — 나누는 것은 받는 쪽 몫이다. +CREW_QUANTITY_MARKS = ("시공량", "작업량", "1일작업량") + + +# ⚠ **이름에 자간 공백이 든 기종·직종** — 같은 표 묶음 안에서도 표기가 갈린다 +# (`13-6-1` 은 「굴 삭 기 (무한궤도)」, 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」). +# 받는 쪽이 이름으로 자원을 찾으므로 **표기가 갈리면 그 줄이 통째로 빠진다.** +# ⚠ **여기서 이름을 고치지 않는다** — 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** +# (서브 창 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 시간당 사용료가 이중). +# 깃발만 실어 받는 쪽이 대조하게 한다. +_SPACED_NAME_RE = re.compile(r"^(?=.*\S\s\S)[가-힣](?:\s+[가-힣])+") +#: 시공능력 공식 파라미터가 값 자리에 온 줄 — **소요량이 아니다.** 그냥 읽으면 이중계상. +_FORMULA_KEYS = frozenset({"K", "k", "f", "E", "Cm", "q", "qo", "Q", "㎝(sec)"}) +#: 자원 줄임을 알리는 단위 칸. 이것과 수치가 함께 있어야 자원으로 본다. +_RESOURCE_UNITS = frozenset( + {"인", "h", "hr", "시간", "대", "매", "본", "개", "kg", "㎏", "㎥", "㎡", "m", "ℓ", "톤", "ton"} +) + + +def spaced_names(table: dict[str, Any]) -> list[str]: + """자간 공백이 든 **자원 이름**. 표기가 갈리는 자리를 드러낸다. + + ⚠ **좁게 잡는다.** 「단 위」·「모 래」 같은 머리글·재료명까지 걸면 101건이 되어 + 목록이 잡음이 되고, 잡음이 되면 아무도 안 본다(오늘 아홉 번 겪은 병). + **그 줄에 단위 칸과 수치가 함께 있는 것**만 자원 줄로 본다. + """ + found: list[str] = [] + for row in table.get("rows", []): + if not row: + continue + name = norm(row[0]) + if not name or not _SPACED_NAME_RE.match(name): + continue + rest = [norm(cell) for cell in row[1:]] + has_unit = any(cell in _RESOURCE_UNITS for cell in rest) + has_number = any(_PLAIN_NUMBER_RE.match(cell) for cell in rest if cell) + if has_unit and has_number and name not in found: + found.append(name) + return found + + +def formula_rows(table: dict[str, Any]) -> list[str]: + """값 자리에 시공능력 공식 기호가 온 줄. 자원으로 세면 이중계상이다.""" + found: list[str] = [] + for row in table.get("rows", []): + for cell in row: + text = norm(cell) + if text in _FORMULA_KEYS and text not in found: + found.append(text) + return found + + +# ⚠ **규격 표기에 쓰이는 특수문자** — 원문이 한 종류로 안 쓴다(2026-09-07 실측). +# 물결표만 셋이다: `∼`(U+223C) 662회 · `~`(U+FF5E) 459회 · `~`(U+007E) 2회. +# 곱셈표도 `×`(U+00D7) 97회 · `x`(U+0078) 4회로 갈린다. +# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다** — 그래서 우리는 +# 키를 조립하지 않고 **저장 원본값만** 보낸다(인계 계약). 여기서는 **표시만** 한다. +# ⚠ 값을 고치지 않는다 — 원문 표기를 흡수하는 것은 **원문을 읽는 쪽**의 몫이다. +SPECIAL_GLYPHS = { + "∼": "∼(U+223C)", + "~": "~(U+FF5E)", + "~": "~(U+007E)", + "×": "×(U+00D7)", + "x": "x(U+0078)", + "–": "–(U+2013)", + "‐": "‐(U+2010)", +} + + +def special_glyphs(table: dict[str, Any]) -> list[str]: + """규격 표기에 쓰인 특수문자 종류. 갈래 키를 맞출 때 대조할 자리.""" + text = " ".join(norm(cell) for row in table.get("rows", []) for cell in row) + return sorted({SPECIAL_GLYPHS[ch] for ch in text if ch in SPECIAL_GLYPHS}) + + +def crew_table(table: dict[str, Any]) -> bool: + """작업조 + 시공량으로 적힌 표인가.""" + hay = " ".join(norm(h) for h in table.get("headers", [])) + body = " ".join(norm(c) for row in table.get("rows", []) for c in row) + squeezed = (hay + " " + body).replace(" ", "") + if not any(mark in squeezed for mark in CREW_QUANTITY_MARKS): + return False + return bool(OCCUPATION_RE.search(squeezed)) + + +def resource_shares(table: dict[str, Any]) -> dict[str, float]: + """`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸.""" + shares: dict[str, float] = {} + for row in table.get("rows", []): + if not row: + continue + if m := SHARE_TAG_RE.match(norm(row[0])): + shares[m.group(1)] = float(m.group(2)) + return shares + + +def capacity_formula_pending(table: dict[str, Any]) -> bool: + """장비 몫이 **시공능력 공식**으로만 적혀 있어 아직 값이 안 된 상태인가.""" + keys = {norm(row[0]) for row in table.get("rows", []) if row} + cells = {norm(c) for row in table.get("rows", []) for c in row} + return bool((keys | cells) & CAPACITY_SYMBOLS) + + +def variant_axis(table: dict[str, Any]) -> list[str]: + """행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다.""" + seen: list[str] = [] + for row in table.get("rows", []): + key = norm(row[0]) if row else "" + if key and key not in seen: + seen.append(key) + return seen[:24] + + +def basis_quantity_is_grouped(quantity: float | None) -> bool: + """「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다.""" + return quantity is not None and abs(quantity - 1.0) > 1e-9 + + +def build() -> dict[str, Any]: + data = json.loads(SOURCE.read_text(encoding="utf-8")) + tables = data["variables"]["pum"]["tables"] + # 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다. + source_lines: list[str] = [] + for entry in data.get("sources") or []: + candidate = ROOT / str(entry.get("path") or "") + if candidate.is_file(): + source_lines = candidate.read_text(encoding="utf-8").splitlines() + break + toc_table = next(t for t in tables if t["table_id"] == "F0001") + nodes = parse_toc(toc_table["rows"]) + by_number = {n["number"]: n for n in nodes} + + attached = 0 + # ⚠ 밑수를 못 찾은 표 목록. 「곱하면 안 되는 줄」을 받는 쪽이 가릴 수 있게 낸다 — + # 빈칸으로 두면 「1단위당」으로 오해되어 곱셈이 10배·100배 틀린다. + basis_missing: list[dict[str, Any]] = [] + basis_found = 0 + basis_grouped = 0 + orphans: list[dict[str, Any]] = [] + undetermined: list[dict[str, Any]] = [] + + for table in tables: + if table["table_id"] == "F0001": + continue # 목차 자신은 공종이 아니다. + section = norm(table.get("section")) + number = section_number(section) + chapter = number.split("-")[0] if number else None + form, why = detect_form(table, chapter) + # ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준 + # (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다. + # 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다. + basis_qty = basis_unit = None + if source_lines: + basis_qty, basis_unit = basis_from_source(source_lines, int(table.get("line") or 0)) + if basis_qty is None and basis_unit is None: + basis_qty, basis_unit = detect_basis(table) + basis_source = "표 안" if basis_unit else None + else: + basis_source = "본문" + if basis_unit: + basis_found += 1 + if basis_quantity_is_grouped(basis_qty): + basis_grouped += 1 + elif form in ("requirement", "productivity"): + # 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다. + basis_missing.append( + { + "pum_table_id": table["table_id"], + "section": norm(table.get("section")), + "pum_form": form, + "line": table.get("line"), + } + ) + shares = resource_shares(table) + # ⚠ 「값이 일부만 선 표」를 정상으로 흘려보내지 않는다. **몫이 적혀 있으면 부분값**으로 + # 본다 — 관측한 25건 모두 장비 몫이 시공능력 공식으로만 적혀 있어 값이 아니었고, + # 공식 기호가 첫 표에만 있고 이어지는 표는 그것을 물려받는 모양이라 + # 「기호가 있는 표만」으로 세면 절반을 놓친다(9-13-2 가 그 경우). + # ⚠ 이 깃발은 **표시일 뿐 값을 지우지 않는다** — 넓게 잡아도 정상 값이 안 사라진다. + partial = bool(shares) + entry = { + "pum_table_id": table["table_id"], + "section": section, + "source_line": table.get("line"), + "pum_form": form, + "form_basis": why, + "basis_quantity": basis_qty, + "basis_unit": basis_unit, + # 밑수를 어디서 읽었나 — 본문(정본) / 표 안(보조). 없으면 None. + "basis_source": basis_source, + "resource_shares": shares, + "partial_ratio": partial, + # ⚠ 값 자리에 식이 적힌 칸 — 그 성분은 숫자로 안 읽히므로 **금액을 만들면 안 된다**. + "expression_cells": expression_cells(table), + # ⚠ 작업조 표 — 「4」가 소요량이 아니라 인원이다. 그냥 읽으면 35배 부푼다. + "crew_table": crew_table(table), + # ⚠ 이름 표기가 갈리는 줄 — 받는 쪽이 이름으로 찾으면 통째로 빠진다. + "spaced_names": spaced_names(table), + # ⚠ 공식 기호 줄 — 소요량이 아니다. 자원으로 세면 이중계상. + "formula_rows": formula_rows(table), + # ⚠ 규격 표기 특수문자 — 갈래 키를 맞출 때 대조할 자리(값은 안 고침). + "special_glyphs": special_glyphs(table), + # 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다. + "capacity_formula_here": capacity_formula_pending(table), + "variant_key": variant_axis(table), + "condition_note": [norm(h) for h in table.get("headers", []) if norm(h)], + "raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다. + } + if form == "undetermined": + undetermined.append( + { + "pum_table_id": table["table_id"], + "section": section, + "headers": entry["condition_note"], + "first_rows": table.get("rows", [])[:2], + "reason": why, + } + ) + node = by_number.get(number) if number else None + if node is None: + orphans.append({"pum_table_id": table["table_id"], "section": section}) + continue + node["tables"].append(entry) + attached += 1 + + src_meta = { + "dataset_id": data["dataset_id"], + "effective_date": data["effective_date"], + "sha256": sha256_of(SOURCE), + "file": SOURCE.name, + } + return ( + { + "schema_version": SCHEMA_VERSION, + "dataset_id": "work_item_master_forest", + "effective_date": data["effective_date"], + "generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "dataset_version": src_meta, + "policy": { + "axis": "work_item_only", + "resource_axis_owner": "B09", + "no_invented_values": True, + "raw_row_preserved": True, + }, + "stats": { + "toc_nodes": len(nodes), + "tables_total": len(tables) - 1, + "tables_attached": attached, + "tables_orphan": len(orphans), + "form_undetermined": len(undetermined), + "basis_found": basis_found, + "basis_missing": len(basis_missing), + "basis_grouped": basis_grouped, + }, + "orphan_tables": orphans, + "work_items": nodes, + }, + undetermined, + basis_missing, + ) + + +def main() -> None: + master, undetermined, basis_missing = build() + OUT_DIR.mkdir(parents=True, exist_ok=True) + date = master["effective_date"] + master_path = OUT_DIR / f"work_item_master_{date}.json" + undet_path = OUT_DIR / f"form_undetermined_{date}.json" + basis_path = OUT_DIR / f"basis_missing_{date}.json" + + master_path.write_text(json.dumps(master, ensure_ascii=False, indent=1), encoding="utf-8") + undet_path.write_text( + json.dumps( + { + "schema_version": SCHEMA_VERSION, + "dataset_id": "work_item_master_form_undetermined", + "effective_date": date, + "note": "형태를 못 정한 표. 사람이 보고 productivity/requirement/coefficient 로 확정할 것.", + "items": undetermined, + }, + ensure_ascii=False, + indent=1, + ), + encoding="utf-8", + ) + + basis_path.write_text( + json.dumps( + { + "schema_version": SCHEMA_VERSION, + "dataset_id": "work_item_master_basis_missing", + "effective_date": date, + "note": ( + "밑수(「10㎡당」 같은 기준 수량)를 못 찾은 표. **1 단위당으로 단정하지 말 것** — " + "곱셈이 10배·100배 틀린다. 값을 곱해야 하는 형태(requirement·productivity)만 담는다." + ), + "items": basis_missing, + }, + ensure_ascii=False, + indent=1, + ), + encoding="utf-8", + ) + + manifest = { + "schema_version": SCHEMA_VERSION, + "dataset_id": "data_work_item_master_manifest", + "generated_at": master["generated_at"], + "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", + "source": master["dataset_version"], + "files": [ + { + "file": p.name, + "sha256": sha256_of(p), + "size_bytes": p.stat().st_size, + } + for p in (master_path, undet_path, basis_path) + ], + } + (OUT_DIR / "_manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=1), encoding="utf-8" + ) + + s = master["stats"] + print(f"목차 계층 {s['toc_nodes']}") + print( + f"표 귀속 {s['tables_attached']} / {s['tables_total']} (미귀속 {s['tables_orphan']})" + ) + print(f"형태 미판정 {s['form_undetermined']}") + print(f"밑수 확보 {s['basis_found']} (묶음 기준 {s['basis_grouped']})") + print(f"밑수 미확보 {s['basis_missing']} → {basis_path.name}") + print(f"산출 {master_path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/B08_Quantity/B08_Quantity_Engine_BasisUnit.py b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py new file mode 100644 index 00000000..c56424cb --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py @@ -0,0 +1,135 @@ +"""품셈 밑수 단위 대조 — **보내는 단위가 그 공종의 밑수와 같은가** (2026-09-08). + +⚠ **왜 있나 — 실제로 금액이 틀렸다.** + 돌쌓기(찰)를 `m · 10.0` 으로 보내는데 품셈 13-4-5 밑수는 **㎡** 였다. 받는 쪽이 그 공종의 + 단가(52,938.9 원/㎡)를 그대로 곱해 **529,389원**이 섰다. 면적으로 세면 26.101㎡ × + 52,938.9 = **1,381,753원** — **2.6배 차이**인데 양쪽 다 오류가 안 났다. + +⚠ **오늘만 다섯 번째 「보내는 쪽과 받는 쪽 사이」 사고다** + 개소가 미터로 나간 것 · 준비공 표가 통째로 안 간 것 · B군 코드가 빈 것 · 성분 줄이 안 + 가는 것 · 이번 단위 불일치. **전부 조용했다.** 한쪽만 보는 시험은 이 자리를 못 잡는다 — + 보내는 값과 **품셈 원문**을 맞대야 잡힌다. 그래서 이 파일은 매핑이 아니라 **마스터**를 본다. + +⚠ **환산하지 않는다.** m 을 ㎡ 로 바꾸는 길은 없다(두께·기울기를 지어내야 한다). + 표기 차이(㎥/m3 · 개소/개)만 같은 것으로 보고, **뜻이 다른 단위는 드러낸다.** +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterable + +MASTER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_master" +MASTER_PREFIX = "work_item_master_" + +#: 같은 뜻인데 표기만 다른 단위 — **환산이 아니라 표기 흡수**다. +UNIT_ALIASES = { + "m3": "㎥", + "m³": "㎥", + "m2": "㎡", + "m²": "㎡", + "개": "개소", + "본": "개소", + "kg": "㎏", + "ton": "t", + "톤": "t", +} + + +def normalize_unit(unit: str) -> str: + """표기만 다른 단위를 한 글자로 모은다. **뜻이 다른 단위는 안 건드린다.**""" + text = str(unit or "").strip() + return UNIT_ALIASES.get(text, text) + + +def load_master(path: Path | None = None) -> dict[str, Any]: + """공종 마스터. 파일이 없으면 빈 표 — 대조를 못 할 뿐 값은 그대로 간다.""" + target = path + if target is None: + files = sorted(MASTER_DIR.glob(MASTER_PREFIX + "*.json")) if MASTER_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def basis_units(master: dict[str, Any] | None = None) -> dict[str, set[str]]: + """공종코드 → 그 공종 표들이 쓰는 밑수 단위 모음. + + ⚠ 한 공종에 표가 여럿이면 단위도 여럿이다(흄관은 「m」와 「개소」 둘 다 있다). + **하나로 줄이지 않는다** — 줄이면 맞는 단위를 틀렸다고 말하게 된다. + """ + found = master if master is not None else load_master() + out: dict[str, set[str]] = {} + for item in found.get("work_items") or []: + code = str(item.get("work_item_code") or "") + if not code: + continue + units = { + normalize_unit(table.get("basis_unit")) + for table in item.get("tables") or [] + if table.get("basis_unit") + } + if units: + out[code] = units + return out + + +def merge_units(table: dict[str, set[str]], extra: dict[str, str] | None) -> dict[str, set[str]]: + """마스터 밑수에 **매핑이 원문에서 읽어 적은 밑수**를 얹는다. + + ⚠ **왜 얹나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서만 온다. 층따기 9-18 + 처럼 **공식 [주]로만 단위가 밝혀지는 공종**은 그 자리가 비어(477 중 밑수가 선 것은 167) + 대조가 조용히 통과한다. 매핑이 적은 값은 **마스터가 빈 자리를 채우고, 있으면 함께 둔다** + (하나로 줄이면 맞는 단위를 틀렸다고 말하게 된다). + """ + if not extra: + return table + merged = {code: set(units) for code, units in table.items()} + for code, unit in extra.items(): + if unit: + merged.setdefault(code, set()).add(normalize_unit(unit)) + return merged + + +def verify_unit_matches_basis( + rows: Iterable[dict[str, Any]], + table: dict[str, set[str]] | None = None, + extra: dict[str, str] | None = None, +) -> list[str]: + """⚠ 인계 줄의 단위가 품셈 밑수와 다르면 알린다. + + **막지는 않는다** — 여기서 줄을 빼면 「빠진 줄」이 되어 더 안 보인다. 사유를 내고 + 사람이 보게 한다. 코드가 없는 줄·수량이 없는 줄·마스터에 없는 코드는 대상이 아니다. + """ + known = merge_units(table if table is not None else basis_units(), extra) + if not known: + return [] + out: list[str] = [] + for row in rows: + code = str(row.get("work_item_code") or "") + unit = normalize_unit(row.get("unit")) + if not code or not unit or code not in known: + continue + if not row.get("in_bill"): + continue + if unit in known[code]: + continue + expected = " · ".join(sorted(known[code])) + out.append( + f"{row.get('name') or code}({code}) — 보내는 단위 「{unit}」가 품셈 밑수 " + f"「{expected}」와 다릅니다. 그대로 곱하면 금액이 틀립니다" + ) + return out + + +def unit_for_code(code: str, table: dict[str, set[str]] | None = None) -> str: + """그 공종이 **한 가지 밑수 단위만** 쓰면 그 단위, 아니면 빈 문자열. + + ⚠ 물량이 못 서는 줄에도 **단위는 맞는 것**을 실으려고 쓴다 — 「0 m」로 내면 받는 쪽이 + 길이로 읽고, 나중에 값이 채워질 때 축이 어긋난 채로 선다. 여럿이면 고르지 않는다. + """ + known = table if table is not None else basis_units() + units = known.get(str(code or ""), set()) + return next(iter(units)) if len(units) == 1 else "" diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py new file mode 100644 index 00000000..f94daae3 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkSummary.py @@ -0,0 +1,289 @@ +"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은다 (B08 일감 4 · PLAN 8-11). + +열 구성 (거창 실무 `토공집계표` 시트 그대로) + `구분 · 공종 · 규격 · 단위 · 계 · 비고` + +암 갈래는 **개수를 코드에 박지 않는다** (PLAN 8-13) + 울진 2갈래(연암·발파암) · 거창 5갈래(토사·풍화암·연암·보통암·경암) · 오솔길 BOM 1갈래로 + 공사마다 다르다. 프로젝트 설정의 세트를 받아 그만큼 줄을 낸다. + + ⚠ 측점별 암질 판정에 기대지 않는다(PLAN 8-1 사용자 확정) — 절토량은 기하에서 나오고 + **암/토사 나눔과 갈래 비율은 설계자 입력**이다. 그래서 여기서는 토적표의 「암」 총량을 + 설계자가 준 비율(%)로 나눠 줄을 만든다. + +⚠ 반영률은 법정값이 아니다 (PLAN 8-11) + 실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **기본 100 %** 이고 + 설계자가 바꾼다. 실무 관측치(80/50/80)는 참고이지 기본값이 아니다. + +⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 되지 않는다** (PLAN 8-7 ㉡) + 품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다. + 그래서 `in_bill=False` 로 표시해 넘긴다 — 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다. +GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기") + +# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %). +RATIO_OF_ROW = { + "성토면다짐": "fill_slope_compaction", + "초류종자살포": "seed_spray_fill", # 성토면 몫에만 걸린다 — 절토면은 별도 키 + "지장목제거": "obstacle_removal", +} + + +@dataclass(slots=True) +class SummaryRow: + """집계표 한 줄. 이름·단위는 거창 실무 시트 문구를 따른다.""" + + group: str # 구분 (흙깎기·성토·…) + item: str = "" # 공종 (토사·연암·…) + spec: str = "" # 규격 (기계(굴삭기)·백호우·…) + unit: str = "㎥" + amount: float = 0.0 # 반영률을 **곱한 뒤** 값 — 내역서에 쓰는 값 + # ⚠ 반영률 **적용 전** 값과 쓴 율을 함께 남긴다 (2026-09-07 3자 계약). + # 곱하기는 **B08 한 곳에서만** 한다. B09 가 율만 보고 또 곱하면 값이 두 배가 된다. + # 반영률 개념이 없는 줄은 `None` 이고, 100 % 인 줄도 **100.0 을 적는다** — + # 칸이 비어 있으면 「적용됐는지」를 받는 쪽이 단정할 수 없다. + amount_gross: float | None = None + application_ratio_pct: float | None = None + # ⚠ 성·절토면이 갈리는 줄은 **늘 갈래별로** 싣는다 (2026-09-07 3자 계약 확정). + # 「율이 같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고 + # 그게 **한쪽만 고쳐지는** 자리가 된다. `application_ratio_pct` 는 두 율이 같을 때만 + # 채우는 **편의값**이고, 정본은 아래 두 칸이다. + application_ratio_breakdown: dict[str, float] | None = None + quantity_breakdown: dict[str, float] | None = None + note: str = "" + # 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡). + in_bill: bool = True + + +@dataclass(slots=True) +class SummaryInput: + """집계에 필요한 값 묶음. 토적표·사면표·운반계획에서 이미 나온 것만 받는다.""" + + earthwork_totals: dict[str, float] = field(default_factory=dict) + slope_totals: dict[str, float] = field(default_factory=dict) + haul_rows: list[dict[str, Any]] = field(default_factory=list) + rock_classes: list[str] = field(default_factory=list) + rock_ratios_pct: dict[str, float] = field(default_factory=dict) + application_ratios: dict[str, float] = field(default_factory=dict) + + +def _ratio(source: SummaryInput, key: str) -> float: + """반영률(0~1). 없으면 1.0 — 실무 관측치를 기본값으로 쓰지 않는다.""" + value = source.application_ratios.get(key) + return float(value) if isinstance(value, (int, float)) else 1.0 + + +def _split_by_rock(total: float, source: SummaryInput) -> list[tuple[str, float, str]]: + """암 총량을 설계자가 준 비율(%)로 갈래별로 나눈다. `(이름, 물량, 비고)`. + + 비율이 아직 없으면 **나누지 않고 「암」 한 줄로** 낸다 — 지어낸 비율로 쪼개지 않는다. + + ⚠ 합이 100 이 아니어도 **총량은 보존**한다 — 준 비율끼리 안분한다. 물량이 조용히 + 사라지면 안 되기 때문이다. 다만 값이 말없이 바뀌는 것이므로 **비고에 드러낸다** + (60/30 을 넣으면 실제로는 66.7/33.3 으로 돈다). + """ + classes = [name for name in source.rock_classes if name != "토사"] + ratios = {name: float(source.rock_ratios_pct.get(name, 0) or 0) for name in classes} + given = sum(ratios.values()) + if given <= 0: + return [("암", total, "")] + note = "" if abs(given - 100.0) < 1e-9 else f"암 갈래 입력 합 {given:g} % → 100 % 로 안분" + return [(name, total * ratios[name] / given, note) for name in classes if ratios[name] > 0] + + +def build_rows(source: SummaryInput) -> list[SummaryRow]: + """토공집계표 줄 목록. 값이 0 인 갈래도 줄은 남긴다(실무 시트가 그렇다).""" + earth = source.earthwork_totals + slope = source.slope_totals + rows: list[SummaryRow] = [] + + # ── 흙깎기 · 측구터파기 — 토사 한 줄 + 암 갈래만큼 ────────────── + for group, soil_key, rock_key in ( + ("흙깎기", "cut_soil_volume_m3", "cut_rock_volume_m3"), + ("측구터파기", "ditch_soil_volume_m3", "ditch_rock_volume_m3"), + ): + rows.append( + SummaryRow( + group=group, item="토사", spec="기계(굴삭기)", amount=earth.get(soil_key, 0.0) + ) + ) + for name, amount, note in _split_by_rock(earth.get(rock_key, 0.0), source): + rows.append( + SummaryRow(group=group, item=name, spec="굴삭기+브레카", amount=amount, note=note) + ) + + rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0))) + rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0))) + + # ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ──────── + rows.extend(_haul_rows(source)) + + # ── 사면 계열 — 반영률이 여기서 걸린다 ──────────────────────── + fill_face = slope.get("face_dressing_fill", 0.0) + cut_face = slope.get("face_dressing_cut", 0.0) + rows.append( + SummaryRow( + group="성토면다짐", + unit="㎡", + amount=fill_face * _ratio(source, "fill_slope_compaction"), + amount_gross=fill_face, + application_ratio_pct=_ratio(source, "fill_slope_compaction") * 100.0, + application_ratio_breakdown={"fill": _ratio(source, "fill_slope_compaction") * 100.0}, + quantity_breakdown={"fill": fill_face * _ratio(source, "fill_slope_compaction")}, + note=_ratio_note(source, "fill_slope_compaction", "성토면"), + ) + ) + seed = fill_face * _ratio(source, "seed_spray_fill") + cut_face * _ratio( + source, "seed_spray_cut" + ) + # ⚠ 성·절토면 율이 다를 수 있어 **한 줄에 하나의 율**로 못 적는다. 적용 전 합을 함께 두고 + # 율은 두 율이 같을 때만 적는다 — 다르면 `None` 이고 비고에 두 율이 적힌다. + seed_gross = fill_face + cut_face + seed_fill_ratio = _ratio(source, "seed_spray_fill") + seed_cut_ratio = _ratio(source, "seed_spray_cut") + rows.append( + SummaryRow( + group="초류종자살포", + spec="씨드스프레이", + unit="㎡", + amount=seed, + amount_gross=seed_gross, + application_ratio_pct=( + seed_fill_ratio * 100.0 if seed_fill_ratio == seed_cut_ratio else None + ), + application_ratio_breakdown={ + "fill": seed_fill_ratio * 100.0, + "cut": seed_cut_ratio * 100.0, + }, + quantity_breakdown={ + "fill": fill_face * seed_fill_ratio, + "cut": cut_face * seed_cut_ratio, + }, + note=_seed_note(source), + ) + ) + removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0) + rows.append( + SummaryRow( + group="지장목제거", + unit="㎡", + amount=removal * _ratio(source, "obstacle_removal"), + amount_gross=removal, + application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0, + application_ratio_breakdown={ + "fill": _ratio(source, "obstacle_removal") * 100.0, + "cut": _ratio(source, "obstacle_removal") * 100.0, + }, + quantity_breakdown={ + "fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"), + "cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"), + }, + note=_ratio_note(source, "obstacle_removal", "성토면+절토면"), + ) + ) + rows.append( + SummaryRow( + group="층따기", spec="백호우", unit="㎡", amount=slope.get("bench_cut_fill", 0.0) + ) + ) + return rows + + +def _ratio_note(source: SummaryInput, key: str, base: str) -> str: + ratio = _ratio(source, key) + return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영" + + +def _seed_note(source: SummaryInput) -> str: + fill = _ratio(source, "seed_spray_fill") + cut = _ratio(source, "seed_spray_cut") + if abs(fill - 1.0) < 1e-9 and abs(cut - 1.0) < 1e-9: + return "" + return f"성토면 {fill * 100:g} % 반영 + 절토면 {cut * 100:g} % 반영" + + +# 운반수단 표기 — `HaulPlan` 의 키를 실무 시트 문구로 옮긴다. +HAUL_LABELS = {"free_haul": "무대(종방향유용토)", "dozer": "도자운반", "dump_truck": "덤프운반"} + + +def _haul_rows(source: SummaryInput) -> list[SummaryRow]: + """운반 — (운반수단 × 지반유형)별 가중평균 줄 (PLAN 8-3). + + 무대는 `in_bill=False` — 품셈 1-2-7 로 품에 포함돼 단가가 없다. 값은 검산에 쓴다. + """ + rows: list[SummaryRow] = [] + for item in source.haul_rows: + key = str(item.get("equipment") or "") + label = HAUL_LABELS.get(key, key or "운반") + ground = str(item.get("ground") or "") + distance = item.get("average_distance_m") + note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else "" + if key == "free_haul": + note = (note + " · 내역 제외(품에 포함)").strip(" ·") + rows.append( + SummaryRow( + group=label, + item=ground, + amount=float(item.get("volume_m3") or 0.0), + note=note, + in_bill=key != "free_haul", + ) + ) + return rows + + +def build_table(source: SummaryInput) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양.""" + rows = build_rows(source) + return { + "columns": ["구분", "공종", "규격", "단위", "계", "비고"], + "rock_classes": list(source.rock_classes), + "rock_ratios_pct": dict(source.rock_ratios_pct), + "application_ratios": dict(source.application_ratios), + "rows": [ + { + "group": row.group, + "item": row.item, + "spec": row.spec, + "unit": row.unit, + "amount": row.amount, + "amount_gross": row.amount_gross, + "application_ratio_pct": row.application_ratio_pct, + "application_ratio_breakdown": row.application_ratio_breakdown, + "quantity_breakdown": row.quantity_breakdown, + "note": row.note, + "in_bill": row.in_bill, + } + for row in rows + ], + "row_count": len(rows), + } + + +def haul_check(source: SummaryInput, earthwork_totals: dict[str, float]) -> dict[str, Any]: + """검산 — `무대 + 도자 + 덤프` 합이 총 운반토량과 맞는가 (PLAN 8-7 ㉡). + + 무대를 안 내면 이 검산이 안 된다. 그래서 값은 내되 내역 줄만 빼는 것이다. + """ + hauled = sum(float(item.get("volume_m3") or 0.0) for item in source.haul_rows) + diverted = float(earthwork_totals.get("diverted_m3") or 0.0) + return { + "hauled_total_m3": hauled, + "diverted_total_m3": diverted, + "difference_m3": hauled - diverted, + } + + +def totals_by_unit(rows: Iterable[SummaryRow]) -> dict[str, float]: + """단위별 합계 — ㎥ 와 ㎡ 를 섞어 더하지 않는다.""" + result: dict[str, float] = {} + for row in rows: + result[row.unit] = result.get(row.unit, 0.0) + row.amount + return result diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py b/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py new file mode 100644 index 00000000..5cec5f9c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py @@ -0,0 +1,209 @@ +"""토적표 — 측점별 단면적을 평균단면적법으로 체적화한다 (B08 일감 2 · PLAN 8-4b). + +무엇을 만드나 + 실무 토적표의 열 구성 그대로다. 거창 실무 워크북 `토적표` 시트와 오솔길 `1.BOM` 36열이 + 서로 1:1 로 맞물리는 것을 확인해 열 이름을 그대로 옮겼다(PLAN 8-4b). + + 측점 · 거리 · 절토[토사·암 각 (단면적·입적·보정량)] · 측구터파기[토사·암 각 3칸] + · 보정량계 · 성토[단면적·입적] · 유용토 · 차인토량 · 누가토량 + + 사면 계열(층따기·면고르기·법면보호공·지장목제거)은 사면길이가 아직 없어 일감 3에서 붙인다. + +평균단면적법 (신규 문서 5장 「다. 공사수량의 산출」) + 체적 = (앞 측점 단면적 + 현 측점 단면적) ÷ 2 × 두 측점 사이 거리. + 첫 측점은 앞이 없으므로 체적이 없다(거창 실무 토적표도 첫 행 체적이 비어 있다). + +보정량 = 체적 × 토량환산계수(다짐) + 절취한 흙이 다져지면 줄거나 부푼다. 성토에 쓸 수 있는 양으로 환산한 것이 보정량이다. + 계수의 유일한 정의처는 `config.config_system_design.EARTHWORK_CONVERSION_FACTORS` 이며 + 여기서 값을 다시 적지 않는다. + +⚠ 숫자는 자르지 않는다 (PLAN 8-16) + 품셈 1-2-2 의 소수 자리는 **표기 규칙**이다. 계산은 전정밀로 두고 화면·출력에서만 + 반올림한다. 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 섞지 말 것. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS + +# 절토 암을 어느 환산계수로 볼지 — 측점의 `cut_rock_kind` 를 그대로 쓴다. +# 값이 없으면 리핑암으로 본다(발파암보다 보수적으로 적은 쪽). +_DEFAULT_ROCK_KIND = "ripping_rock" + + +def _factor(kind: str) -> float: + """지반유형 → 다짐 환산계수. 모르는 유형이면 토사로 본다.""" + entry = EARTHWORK_CONVERSION_FACTORS.get(kind) or EARTHWORK_CONVERSION_FACTORS["soil"] + return float(entry["compacted"]) + + +@dataclass(slots=True) +class StationArea: + """토적표 한 줄이 필요로 하는 측점 값. B06 설계 결과에서 그대로 옮겨 담는다.""" + + chainage_m: float + cut_soil_area_m2: float = 0.0 + cut_rock_area_m2: float = 0.0 + fill_area_m2: float = 0.0 + ditch_area_m2: float = 0.0 + cut_rock_kind: str | None = None + + @classmethod + def from_design(cls, chainage_m: float, design: dict[str, Any]) -> "StationArea": + def num(key: str) -> float: + value = design.get(key) + return float(value) if isinstance(value, (int, float)) else 0.0 + + return cls( + chainage_m=float(chainage_m), + cut_soil_area_m2=num("cut_soil_area_m2"), + cut_rock_area_m2=num("cut_rock_area_m2"), + fill_area_m2=num("fill_area_m2"), + ditch_area_m2=num("ditch_area_m2"), + cut_rock_kind=design.get("cut_rock_kind") or None, + ) + + +@dataclass(slots=True) +class EarthworkRow: + """토적표 한 줄. 열 이름은 실무 토적표(PLAN 8-4b)를 따른다.""" + + chainage_m: float + distance_m: float = 0.0 + cut_soil_area_m2: float = 0.0 + cut_soil_volume_m3: float = 0.0 + cut_soil_adjusted_m3: float = 0.0 + cut_rock_area_m2: float = 0.0 + cut_rock_volume_m3: float = 0.0 + cut_rock_adjusted_m3: float = 0.0 + ditch_soil_area_m2: float = 0.0 + ditch_soil_volume_m3: float = 0.0 + ditch_soil_adjusted_m3: float = 0.0 + ditch_rock_area_m2: float = 0.0 + ditch_rock_volume_m3: float = 0.0 + ditch_rock_adjusted_m3: float = 0.0 + adjusted_total_m3: float = 0.0 + fill_area_m2: float = 0.0 + fill_volume_m3: float = 0.0 + diverted_m3: float = 0.0 + balance_m3: float = 0.0 + cumulative_m3: float = 0.0 + notes: list[str] = field(default_factory=list) + + +def _split_ditch(area: StationArea) -> tuple[float, float]: + """측구터파기 단면적을 토사·암으로 가른다. + + ⚠ TODO(미결 · PLAN 8-4b) — 설계가 측구를 토사·암으로 나눠 주지 않는다(`ditch_area_m2` + 한 값뿐). 실무 토적표는 둘로 갈라 적으므로, **그 측점의 절토 토사:암 면적비로 안분**한다. + 측구는 절토부에 파므로 같은 지반을 만난다는 것이 근거다. 설계가 측구 지반을 따로 내주게 + 되면 이 함수만 갈아끼운다. + """ + ditch = area.ditch_area_m2 + if ditch <= 0: + return 0.0, 0.0 + soil, rock = area.cut_soil_area_m2, area.cut_rock_area_m2 + total = soil + rock + if total <= 0: + return ditch, 0.0 # 절토가 없으면 토사로 본다. + return ditch * soil / total, ditch * rock / total + + +def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]: + """측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다.""" + ordered = sorted(stations, key=lambda s: s.chainage_m) + rows: list[EarthworkRow] = [] + previous: StationArea | None = None + previous_ditch: tuple[float, float] = (0.0, 0.0) + cumulative = 0.0 + + for station in ordered: + ditch_soil, ditch_rock = _split_ditch(station) + soil_factor = _factor("soil") + rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND) + row = EarthworkRow( + chainage_m=station.chainage_m, + cut_soil_area_m2=station.cut_soil_area_m2, + cut_rock_area_m2=station.cut_rock_area_m2, + ditch_soil_area_m2=ditch_soil, + ditch_rock_area_m2=ditch_rock, + fill_area_m2=station.fill_area_m2, + ) + if previous is not None: + distance = station.chainage_m - previous.chainage_m + row.distance_m = distance + + def mean_volume(before: float, now: float) -> float: + return (before + now) / 2.0 * distance + + row.cut_soil_volume_m3 = mean_volume( + previous.cut_soil_area_m2, station.cut_soil_area_m2 + ) + row.cut_rock_volume_m3 = mean_volume( + previous.cut_rock_area_m2, station.cut_rock_area_m2 + ) + row.ditch_soil_volume_m3 = mean_volume(previous_ditch[0], ditch_soil) + row.ditch_rock_volume_m3 = mean_volume(previous_ditch[1], ditch_rock) + row.fill_volume_m3 = mean_volume(previous.fill_area_m2, station.fill_area_m2) + + row.cut_soil_adjusted_m3 = row.cut_soil_volume_m3 * soil_factor + row.cut_rock_adjusted_m3 = row.cut_rock_volume_m3 * rock_factor + row.ditch_soil_adjusted_m3 = row.ditch_soil_volume_m3 * soil_factor + row.ditch_rock_adjusted_m3 = row.ditch_rock_volume_m3 * rock_factor + + row.adjusted_total_m3 = ( + row.cut_soil_adjusted_m3 + + row.cut_rock_adjusted_m3 + + row.ditch_soil_adjusted_m3 + + row.ditch_rock_adjusted_m3 + ) + # 유용토 = 그 측점에서 절취분과 성토분이 서로 만나는 몫. + row.diverted_m3 = min(row.adjusted_total_m3, row.fill_volume_m3) + row.balance_m3 = row.adjusted_total_m3 - row.fill_volume_m3 + cumulative += row.balance_m3 + row.cumulative_m3 = cumulative + + rows.append(row) + previous = station + previous_ditch = (ditch_soil, ditch_rock) + return rows + + +def totals(rows: list[EarthworkRow]) -> dict[str, float]: + """합계 행. 단면적은 합이 뜻이 없어 싣지 않는다(실무 토적표도 비워 둔다).""" + keys = ( + "distance_m", + "cut_soil_volume_m3", + "cut_soil_adjusted_m3", + "cut_rock_volume_m3", + "cut_rock_adjusted_m3", + "ditch_soil_volume_m3", + "ditch_soil_adjusted_m3", + "ditch_rock_volume_m3", + "ditch_rock_adjusted_m3", + "adjusted_total_m3", + "fill_volume_m3", + "diverted_m3", + "balance_m3", + ) + return {key: sum(getattr(row, key) for row in rows) for key in keys} + + +def build_table(stations: Iterable[StationArea]) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16).""" + rows = build_rows(stations) + return { + "method": "average_end_area", + "conversion_factors": EARTHWORK_CONVERSION_FACTORS, + "rows": [row.__dict__ if not hasattr(row, "__slots__") else _as_dict(row) for row in rows], + "totals": totals(rows), + "station_count": len(rows), + } + + +def _as_dict(row: EarthworkRow) -> dict[str, Any]: + return {name: getattr(row, name) for name in EarthworkRow.__slots__} diff --git a/B08_Quantity/B08_Quantity_Engine_Formwork.py b/B08_Quantity/B08_Quantity_Engine_Formwork.py new file mode 100644 index 00000000..65c80556 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Formwork.py @@ -0,0 +1,130 @@ +"""거푸집 사용횟수 — 접촉 면적에 **몇 회 쓰는 거푸집인지**를 붙인다 (B08 일감 ⑩). + +⚠ 사용횟수는 **관측값이 아니라 법이다** + 품셈 1-7-1 이 구조물 종류별로 정해 둔다 — 「3회 … 옹벽, 파라펫트, 날개벽 등 약간 복잡한 + 구조」. 그래서 실무 관측값으로 갈음하지 않고 **원문 문구를 그대로 데이터에 싣고** 우리 + 구조물이 그 줄의 어느 예시에 걸리는지를 적는다. 걸리는 예시가 없으면 지어내지 않는다. + +⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다** (이중계상) + 품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」(합판 3회 46.1 % 등)은 **일위대가 + 재료비**에 걸리는 값이다. B08 이 면적에 그 비율을 곱해 넘기면 B09 가 또 곱해 두 번 준다. + **B08 이 내는 것은 「접촉 면적 + 몇 회짜리인가」까지다.** 비율표는 참고로만 싣는다. + +⚠ 동바리는 지금 대상이 없다 + 강관동바리(12-20)는 **슬래브를 떠받칠 때** 쓴다. 지금 서는 구조물(옹벽·집수정)은 벽체 + 거푸집뿐이라 대상이 아니고, 대상이 될 BOX암거·세월교는 치수·원단위가 미확보라 + 슬래브 면적 자체가 안 나온다. **없는 것을 0 으로 적지 않고 「대상 없음」이라고 말한다.** +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_formwork" +DATASET_PREFIX = "formwork_reuse_" + +#: 거푸집으로 보는 성분 이름. 정확히 같은 이름으로만 본다 — 부분일치면 「거푸집씻기」가 걸린다. +FORMWORK_NAMES = frozenset({"합판거푸집", "유로폼", "문양거푸집", "거푸집"}) + +NOTE_REUSE_MISSING = "사용횟수 미확보" +NOTE_NOT_APPLICABLE = "거푸집 대상 아님" + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class FormworkTable: + """사용횟수표 한 판.""" + + effective_date: str = "" + source: dict[str, Any] = field(default_factory=dict) + type_map: list[dict[str, Any]] = field(default_factory=list) + reuse_by_class: list[dict[str, Any]] = field(default_factory=list) + reuse_ratio_pct: dict[str, Any] = field(default_factory=dict) + shoring: dict[str, Any] = field(default_factory=dict) + #: 유로폼 설치·해체 유형 — 품셈 12-38-3 [주]④ 가 시설 예시로 갈라 둔 것. + euroform_type: dict[str, Any] = field(default_factory=dict) + + def for_type(self, type_id: str) -> dict[str, Any] | None: + for row in self.type_map: + if row.get("type_id") == type_id: + return row + return None + + +def load_formwork_table(path: Path | None = None) -> FormworkTable: + """사용횟수표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return FormworkTable() + payload = json.loads(target.read_text(encoding="utf-8")) + return FormworkTable( + effective_date=str(payload.get("effective_date") or ""), + source=payload.get("source") or {}, + type_map=list(payload.get("type_map") or []), + reuse_by_class=list(payload.get("reuse_by_class") or []), + reuse_ratio_pct=payload.get("reuse_ratio_pct") or {}, + shoring=payload.get("shoring") or {}, + euroform_type=payload.get("euroform_type") or {}, + ) + + +def annotate( + structures: list[dict[str, Any]], table: FormworkTable | None = None +) -> tuple[list[str], list[str]]: + """산출물의 거푸집 성분에 사용횟수를 달아 준다. (알림, 미확보 종류) 를 돌려준다. + + 성분 딕셔너리를 **그 자리에서** 고친다 — 거푸집 줄만 손대고 나머지는 건드리지 않는다. + """ + found = table or load_formwork_table() + notes: list[str] = [] + missing: list[str] = [] + for structure in structures: + type_id = str(structure.get("type_id") or "") + entry = found.for_type(type_id) + targets = [ + component + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in FORMWORK_NAMES + ] + if not targets: + continue + if entry is None: + missing.append(type_id) + for component in targets: + component["reuse_count"] = None + component["reuse_note"] = NOTE_REUSE_MISSING + continue + count = entry.get("reuse_count") + for component in targets: + component["reuse_count"] = count + component["reuse_note"] = ( + f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}」" + if count + else NOTE_NOT_APPLICABLE + ) + if count: + notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)") + else: + missing.append(type_id) + return notes, sorted(set(missing)) + + +def shoring_status(table: FormworkTable | None = None) -> dict[str, Any]: + """동바리 — **대상이 없으면 없다고 말한다.** 0 으로 적으면 「없음」과 구별이 안 된다.""" + found = table or load_formwork_table() + shoring = found.shoring or {} + return { + "applicable": False, + "reason": str(shoring.get("note") or "슬래브 구조물이 없어 동바리 대상이 아님"), + "pending_types": list(shoring.get("targets_pending") or []), + } diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py new file mode 100644 index 00000000..b24a0e0d --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -0,0 +1,250 @@ +"""B08 → B09 인계 (일감 9 · PLAN 8-2 · 2026-09-07 3자 조율 확정). + +**두 벌로 낸다. 한 벌로 합치지 않는다.** + ① `work_items` = **작업 공종** 축. B09 ④예산내역서가 되는 줄이다. 공종코드가 붙는다. + ② `materials` = **자재** 축. B09 자재대가 되는 줄이다. **공종코드가 붙지 않는다.** + +⚠ 자재 줄에 공종코드를 붙이지 않는 까닭 + 내역 줄의 실체는 `돌쌓기(찰) H=1.5 · 70m` 이지 그 전개인 콘크리트 0.31㎥ 가 아니다 + (8-2 이중계상 함정). 자재에 공종코드를 붙이면 **자재가 내역 줄로 오해될 자리**가 생긴다. + 자재를 카탈로그 키에 잇는 것은 **B09 자원 축의 일**이다(8-7). + +⚠ 할증 전/후는 **자재 쪽에만** 있다 + 작업 공종에는 할증이 없다. 자재는 `net_amount`(전)·`total_amount`(후)를 둘 다 넘긴다 — + 하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. + +⚠ `in_bill` 을 반드시 싣는다 (㉡) + 무대(소운반 20m)처럼 **값은 내되 내역에 안 서는** 줄이 있다. B09 가 이 깃발을 안 보면 + ④예산내역서에 무대가 서서 운반비가 두 번 붙는다. 빼고 넘기지 않는 까닭은, + 빠진 줄과 제외된 줄을 나중에 구별할 수 없기 때문이다. + +⚠ **반영률은 B08 한 곳에서만 곱한다** (2026-09-07 3자 계약) + `quantity` 는 **곱한 뒤** 값이고 `quantity_gross` 는 곱하기 전 값이며 `application_ratio_pct` + 는 쓴 율이다. **셋을 함께 싣는 까닭**은 받는 쪽이 「이미 곱해졌나」를 단정할 수 있어야 + 하기 때문이다 — 율만 보내면 B09 가 또 곱해 값이 두 배가 된다. 100 % 인 줄도 `100.0` 을 + 적고, `None` 은 **반영률 개념이 없는 줄**에만 쓴다. + `verify_ratio_math()` 가 세 값이 서로 맞는지 실제로 재 본다. + 성·절토면이 갈리는 줄은 **늘** `application_ratio_breakdown`(갈래별 율)과 + `quantity_breakdown`(갈래별 물량)을 싣는다. `application_ratio_pct` 는 두 율이 같을 때만 + 채우는 **편의값**이다 — 「같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 + 둘 생기고 그게 한쪽만 고쳐지는 자리가 된다(2026-09-07 3자 계약). + +⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택) + 값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을 + 세울 수 있다(울진 2 · 거창 5 · 오솔길 1). 설정 파일을 안 봐도 **인계본만으로 ④가 서게** 한다. + +⚠ 못 이은 줄은 **빈 코드로 두지 않는다** + `unmatched_work_items` 로 낸다. 빈칸이면 「코드가 없는 줄」과 「매핑을 못 찾은 줄」이 + 구별되지 않고, 조용히 없어진 줄은 아무도 못 찾는다. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis + +# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게 +# 여기서 다시 내보낸다(2026-09-08 분리). +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401 + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + ORIGIN_EARTHWORK, + ORIGIN_HAUL, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_SLOPE, + ORIGIN_STRUCTURE, + REBAR_PREFIXES, + SLOPE_GROUPS, + SUBTOTAL_GROUPS, + WorkItemMapping, + composite_quantities, + euroform_type, + load_mapping, + load_masonry_table, + load_rebar_table, + load_timber_table, + masonry_class, + normalize_kind_key, + placing_code, + rebar_complexity, + structure_kind, + timber_class, +) +from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import ( # noqa: F401 + PLACING_TARGET_NAMES, + _earthwork_rows, + _haul_rows, + _length_rows, + _material_rows, + _pipe_rows, + _placing_rows, + _preparation_rows, + _structure_rows, + blocked_of, +) +from common_util.common_util_quantity_spread import spread_by_unit + + +def build_handoff( + *, + summary_table: dict[str, Any] | None = None, + haul_table: dict[str, Any] | None = None, + unit_quantity_table: dict[str, Any] | None = None, + material_table: dict[str, Any] | None = None, + preparation_table: dict[str, Any] | None = None, + length_table: list[dict[str, Any]] | None = None, + pipe_table: dict[str, Any] | None = None, + mapping: WorkItemMapping | None = None, + ground_class_set: str | None = None, + ground_classes: list[str] | None = None, + ground_methods: dict[str, str | None] | None = None, + concrete_placing_method: str | None = None, +) -> dict[str, Any]: + """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.""" + table = mapping or load_mapping() + work_items: list[dict[str, Any]] = [] + unmatched: list[str] = [] + + methods = {key: value for key, value in (ground_methods or {}).items() if value} + if summary_table: + rows, misses = _earthwork_rows(summary_table, table, methods) + work_items.extend(rows) + unmatched.extend(misses) + if haul_table: + rows, misses = _haul_rows(haul_table, table) + work_items.extend(rows) + unmatched.extend(misses) + if unit_quantity_table: + rows, misses = _structure_rows(unit_quantity_table, table) + work_items.extend(rows) + unmatched.extend(misses) + + # 준비공·사방공 — **못 내는 줄도 사유와 함께** 보낸다(빼면 빠진 줄이 안 보인다). + # B군 종단배수 — 겹침을 합친 연장으로 종류별 한 줄(위 `_length_rows` 주석). + work_items.extend(_length_rows(length_table or [], table)) + work_items.extend(_preparation_rows(preparation_table or {})) + # 배수관 — 정본 셋(관 지점·측점 연장·매핑)을 이은 결과. 못 서는 줄도 사유와 함께 감. + work_items.extend(_pipe_rows(pipe_table or {})) + + # 콘크리트 타설 — 품은 이 줄, 재료는 자재 쪽. 겹치지 않는다(위 `_placing_rows` 주석). + placing_rows, placing_notes = _placing_rows( + unit_quantity_table or {}, table, concrete_placing_method + ) + work_items.extend(placing_rows) + + materials = _material_rows(material_table or {}) + result: dict[str, Any] = { + "work_items": work_items, + "materials": materials, + # 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다. + "ground_class_set": ground_class_set, + "ground_classes": list(ground_classes or []), + "ground_methods": dict(methods), + # 시공법을 안 정해 공종을 못 고른 갈래 — 화면이 이 목록으로 안내를 띄운다. + "missing_method_classes": sorted( + { + str(row.get("ground_class")) + for row in work_items + if row.get("ground_class") + and row.get("ground_class") != "토사" + and row.get("work_item_code") is None + and row.get("origin") == ORIGIN_EARTHWORK + } + ), + # 자재 쪽에만 할증이 있다 — 작업 공종에는 없다. + # ⚠ 세 갈래로 그대로 나른다(`applied`·`not_applied`·`rate_unavailable`). + # 「율이 없어 못 붙인 것」을 「붙였다」로 말하면 B09 가 나중에 한 번 더 붙인다. + "surcharge_status": (material_table or {}).get("surcharge_status"), + "surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")), + "unmatched_work_items": sorted(set(unmatched)), + "mapping_pending_user": table.pending_user, + "mapping_edition": table.effective_date, + "quantity_spread": spread_by_unit( + [row for row in work_items if row["in_bill"]], value_key="quantity" + ), + "material_spread": spread_by_unit( + [{"unit": row["unit"], "q": row["total_amount"]} for row in materials], value_key="q" + ), + "bill_row_count": sum(1 for row in work_items if row["in_bill"]), + "excluded_row_count": sum(1 for row in work_items if not row["in_bill"]), + } + # ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다. + # 2026-09-08 ㉘ 자기 감사: 아래 줄 하나만 이어져 있고 형제 둘은 **시험에서만** 불리고 + # 있었다. B09 에서 같은 병(가드 둘이 놀고 있음)을 지적해 놓고 내 쪽도 같았다. + result["ratio_math_warnings"] = verify_ratio_math(result) + result["material_code_warnings"] = verify_no_code_on_materials(result) + result["bill_flag_warnings"] = verify_bill_flags(result) + # ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08). + result["basis_unit_warnings"] = verify_unit_matches_basis( + work_items, extra=table.declared_units() + ) + result["placing_notes"] = placing_notes + return result + + +def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]: + """⚠ 자재 줄에 공종코드가 섞이면 알린다. + + 자재에 공종코드가 붙으면 **내역 줄로 오해될 자리**가 생기고 그게 곧 이중계상이다. + 축이 둘이라는 것은 주석이 아니라 검사로 지켜야 한다. + """ + found: list[str] = [] + for row in handoff.get("materials") or []: + if row.get("work_item_code"): + found.append(str(row.get("material_name"))) + return found + + +def verify_ratio_math(handoff: dict[str, Any], *, tolerance: float = 1e-6) -> list[str]: + """⚠ `quantity == quantity_gross × 율/100` 이 실제로 맞는지 재 본다. + + 세 칸을 실어 두고 **서로 어긋나면** 받는 쪽이 어느 값을 믿을지 알 수 없다. + 「만들어 두고 안 부르면 없는 것과 같다」를 피하려고 `build_handoff()` 가 직접 부른다. + """ + found: list[str] = [] + for row in handoff.get("work_items") or []: + gross = row.get("quantity_gross") + ratio = row.get("application_ratio_pct") + if gross is None or ratio is None: + continue + expected = float(gross) * float(ratio) / 100.0 + actual = float(row.get("quantity") or 0.0) + if abs(expected - actual) > max(tolerance, abs(expected) * 1e-9): + found.append(f"{row.get('name')}: {actual:g} ≠ {gross:g} × {ratio:g} %") + return found + + +def verify_bill_flags(handoff: dict[str, Any]) -> list[str]: + """⚠ 코드가 없는데 내역에 서는 줄이 있으면 알린다. + + 빈 코드로 내역에 세우면 B09 가 단가를 못 붙인 채 0원 줄을 만든다. + """ + found: list[str] = [] + for row in handoff.get("work_items") or []: + if not row.get("in_bill") or row.get("work_item_code"): + continue + # 묶음으로 서는 줄은 코드가 없어도 정상이다 — 무엇으로 묶이는지 적혀 있다. + if row.get("composite_parts"): + continue + found.append(str(row.get("name"))) + return found + + +def summarize(handoff: dict[str, Any]) -> dict[str, Any]: + """화면 안내용 한 줄 요약 — 넘긴 줄과 못 이은 줄을 함께 보인다.""" + return { + "bill_rows": handoff.get("bill_row_count", 0), + "excluded_rows": handoff.get("excluded_row_count", 0), + "materials": len(handoff.get("materials") or []), + "unmatched": handoff.get("unmatched_work_items") or [], + } + + +def iter_bill_rows(handoff: dict[str, Any]) -> Iterable[dict[str, Any]]: + """내역에 서는 줄만 — B09 ④예산내역서가 쓰는 입구.""" + return (row for row in handoff.get("work_items") or [] if row["in_bill"]) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py new file mode 100644 index 00000000..19f2c66c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -0,0 +1,430 @@ +"""공종 매핑과 갈래표 — **어느 품셈 공종에 잇나** (`Engine_Handoff` 에서 갈라냄). + +⚠ **왜 갈랐나** — `Engine_Handoff.py` 가 1,279줄로 700줄 제한의 두 배였다(2026-09-08). + **인계 계약(줄의 모양)은 하나도 안 바뀐다** — 파일만 가른다. 부르는 쪽은 종전대로 + `B08_Quantity_Engine_Handoff` 에서 그대로 가져다 쓴다(그쪽이 다시 내보낸다). + +여기 있는 것 — 매핑표 읽기 · `WorkItemMapping` · 갈래표(돌쌓기·철근·유로폼·목재공작물) · +묶음 전개 · 줄의 출처·막힘 갈래 이름. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" +DATASET_PREFIX = "work_item_mapping_" + +#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이 +#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다. +REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar" +REBAR_PREFIX = "rebar_complexity_" + +#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다). +MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +MASONRY_PREFIX = "masonry_class_" + + +#: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다. +TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber" +TIMBER_PREFIX = "timber_structure_class_" + + +def load_timber_table(path: Path | None = None) -> dict[str, Any]: + """목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" + target = path + if target is None: + files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]: + """(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.** + + ⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다. + 밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③). + """ + found = table if table is not None else load_timber_table() + for row in (found or {}).get("type_map") or []: + if row.get("type_id") == type_id and row.get("class"): + basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}」" + if row.get("provisional"): + basis += f" · ⚠ 잠정 — {row.get('compare', '')}" + return str(row["class"]), basis, bool(row.get("provisional")) + return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False + + +def load_masonry_table(path: Path | None = None) -> dict[str, Any]: + """돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" + target = path + if target is None: + files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def masonry_class( + options: dict[str, Any], table: dict[str, Any] | None = None +) -> tuple[str | None, str]: + """돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다. + + ⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다. + **저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다. + """ + found = table if table is not None else load_masonry_table() + spec = (found or {}).get("back_length") or {} + from B08_Quantity.B08_Quantity_Wording import option_missing + + option_key = str(spec.get("option_key") or "back_len_cm") + raw = options.get(option_key) + if raw is None: + # ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지. + # 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다. + return None, option_missing(option_key) + " (단가 갈래를 못 고름)" + try: + value = float(raw) + except (TypeError, ValueError): + return None, f"뒷길이 값을 못 읽음({raw!r})" + for row in spec.get("classes") or []: + if value <= float(row["max_cm"]): + return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간" + return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음" + + +def normalize_kind_key(label: str) -> str: + """갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정). + + 원문 표는 「보 통」처럼 자간 공백이 들어 있어 그대로 쓰면 양쪽이 안 맞는다. + ⚠ **다른 글자는 손대지 않는다** — 정규화를 넓히면 오늘 아홉 번 겪은 그 병을 + 여기서 새로 만든다. 원문 문구는 버리지 않고 `label` 로 함께 싣는다. + """ + return "".join(str(label).split()) + + +#: 줄이 어디서 왔나 — 되짚을 때 쓴다. +ORIGIN_EARTHWORK = "earthwork" +ORIGIN_STRUCTURE = "structure" +ORIGIN_SLOPE = "slope" +ORIGIN_HAUL = "haul" +ORIGIN_PREPARATION = "preparation" +ORIGIN_PIPE = "pipe" + +#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에 +#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). +METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"} +NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" + +#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 — +#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이 +#: 아니라 공종 이름이라 성분 목록에는 안 온다. +REBAR_PREFIXES = ("이형철근", "원형철근", "철근") + +#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 +#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다. +BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림 +BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음 +BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 + +#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. +SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) + +#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. +SUBTOTAL_GROUPS = frozenset({"보정량계"}) + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +#: ⚠ **공종 수량 단위가 아닌 것** — 품셈 절 머리에 섞여 있는 모양들이다(2026-09-08 B09 전수). +#: `(㎥/1대, 1일)` 는 **시공량**이라 「1대가 하루에 몇 ㎥」이고, 밑수(「1㎥ 에 얼마」)의 +#: **역수**다. 그대로 밑수로 받으면 그 공종이 조용히 뒤집힌 단위를 갖는다. +#: `(일당)`·`(조)`·`(인)` 은 **품의 단위**이지 공종 단위가 아니다. +NON_QUANTITY_UNITS = frozenset({"일당", "조", "인", "일", "대", "인당"}) + + +def is_quantity_unit(unit: str) -> bool: + """그 글자가 **공종 수량의 밑수 단위**로 쓸 수 있는가. + + ⚠ **좁게 본다** — 애매하면 안 받는다. 안 받으면 대조가 없을 뿐이고, 잘못 받으면 + **틀린 단위로 검사를 통과시킨다**(그쪽이 더 나쁘다). + """ + text = str(unit or "").strip() + if not text or text in NON_QUANTITY_UNITS: + return False + # 「㎥/1대, 1일」처럼 나눗셈·쉼표가 있으면 시공량이거나 밑수가 둘이다. + return "/" not in text and "," not in text + + +@dataclass +class WorkItemMapping: + """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" + + effective_date: str = "" + earthwork: list[dict[str, Any]] = field(default_factory=list) + haul: list[dict[str, Any]] = field(default_factory=list) + structure: list[dict[str, Any]] = field(default_factory=list) + pending_user: dict[str, Any] = field(default_factory=dict) + composite: dict[str, Any] = field(default_factory=dict) + concrete_placing: dict[str, Any] = field(default_factory=dict) + unit_conversion: dict[str, Any] = field(default_factory=dict) + #: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다. + pipe: dict[str, Any] = field(default_factory=dict) + + def declared_units(self) -> dict[str, str]: + """공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다. + + ⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가 + 밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를 + 함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다. + """ + found: dict[str, str] = {} + for row in (*self.earthwork, *self.haul, *self.structure): + code, unit = row.get("work_item_code"), row.get("basis_unit") + if code and unit and is_quantity_unit(str(unit)): + found[str(code)] = str(unit) + return found + + def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 + """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" + exact = [ + row + for row in self.earthwork + if row.get("group") == group and row.get("ground") == ground + ] + if exact: + return exact[0] + # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. + loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] + return loose[0] if loose else None + + def for_haul(self, equipment: str) -> dict[str, Any] | None: + for row in self.haul: + if row.get("equipment") == equipment: + return row + return None + + def for_structure(self, type_id: str) -> dict[str, Any] | None: + for row in self.structure: + if row.get("type_id") == type_id: + return row + return None + + def composite_for(self, type_id: str) -> dict[str, Any] | None: + """품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가. + + 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다. + """ + for row in self.composite.get("items") or []: + if row.get("type_id") == type_id: + return row + return None + + +def load_mapping(path: Path | None = None) -> WorkItemMapping: + """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return WorkItemMapping() + payload = json.loads(target.read_text(encoding="utf-8")) + return WorkItemMapping( + effective_date=str(payload.get("effective_date") or ""), + earthwork=list(payload.get("earthwork") or []), + haul=list(payload.get("haul") or []), + structure=list(payload.get("structure") or []), + pending_user=payload.get("pending_user") or {}, + composite=payload.get("composite") or {}, + concrete_placing=payload.get("concrete_placing") or {}, + pipe=payload.get("pipe") or {}, + unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {}, + ) + + +def composite_quantities( + structure: dict[str, Any], + composite: dict[str, Any], + mapping: WorkItemMapping, +) -> tuple[list[dict[str, Any]], list[str]]: + """묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유). + + ⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로 + 못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다. + ⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다. + 안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다. + """ + # ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면 + # **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다. + if not (structure.get("components") or []): + note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" + return [], [{"code": None, "reason": note}] + + amounts: dict[str, tuple[float, str]] = {} + for component in structure.get("components") or []: + name = str(component.get("name") or "").strip() + amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) + kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) + + parts: list[dict[str, Any]] = [] + missing: list[str] = [] + for spec in composite.get("parts") or []: + if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다 + parts.append({"code": str(spec)}) + continue + sources = list(spec.get("from_components") or []) + found = [name for name in sources if name in amounts] + total = sum(amounts[name][0] for name in found) + if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": + total *= kg_to_ton + kinds = { + component.get("basis_kind") + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in found + } + suffix = spec.get("kind_suffix") + entry: dict[str, Any] = { + "code": spec.get("code"), + "name": spec.get("name"), + "unit": spec.get("unit"), + "quantity": total if found else None, + # 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다. + "basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None), + "from_components": sources, + } + if spec.get("incomplete_note"): + # ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다. + # 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다. + entry["incomplete_note"] = spec["incomplete_note"] + if suffix == "euroform_type": + kind, why = euroform_type(str(structure.get("type_id") or "")) + entry["kind"] = normalize_kind_key(kind) if kind else None + entry["kind_label"] = kind # 원문 문구 그대로 + entry["kind_basis"] = why + if kind: + entry["code"] = f"{spec.get('code')}#{normalize_kind_key(kind)}" + else: + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) + if suffix == "rebar_complexity": + # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. + complexity, why = rebar_complexity( + str(structure.get("type_id") or ""), structure.get("options") or {} + ) + entry["kind"] = normalize_kind_key(complexity) if complexity else None + entry["kind_label"] = complexity # 원문 문구 그대로(자간 공백 포함) + entry["kind_basis"] = why + if complexity: + entry["code"] = f"{spec.get('code')}#{normalize_kind_key(complexity)}" + else: + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) + if spec.get("not_ready") or not found: + entry["not_ready"] = True + entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음") + # ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다. + missing.append({"code": spec.get("code"), "reason": entry["why"]}) + parts.append(entry) + return parts, missing + + +def structure_kind(structure: dict[str, Any]) -> str: + """콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.** + + 사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이 + 실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다. + """ + for component in structure.get("components") or []: + name = str(component.get("name") or "").strip() + if any(name.startswith(prefix) for prefix in REBAR_PREFIXES): + return "철근구조물" + return "무근구조물" + + +def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]: + """(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다.""" + table = mapping.concrete_placing or {} + codes = table.get("method_codes") or {} + default = str(table.get("default_method") or "") + if method in codes: + return codes[method], False + return codes.get(default), True + + +def load_rebar_table(path: Path | None = None) -> dict[str, Any]: + """철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다.""" + target = path + if target is None: + files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def rebar_complexity( + type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None +) -> tuple[str | None, str]: + """(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`. + + 품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**· + 교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」. + 사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.** + """ + found = table if table is not None else load_rebar_table() + form = options.get("form") + fallback: dict[str, Any] | None = None + for row in found.get("form_map") or []: + if row.get("type_id") != type_id: + continue + if "form" not in row: + fallback = row + continue + if row.get("form") == form: + if row.get("class"): + return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」" + return None, str(row.get("why") or "원문 예시에 없음") + if fallback and fallback.get("class"): + return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」" + from B08_Quantity.B08_Quantity_Wording import type_label + + detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" + return None, ( + f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 " + "철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다" + ) + + +def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]: + """유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. + + 「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래 + (12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다. + + ⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고 + 1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다. + """ + from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table + + found = table if table is not None else load_formwork_table().euroform_type + for row in (found or {}).get("type_map") or []: + if row.get("type_id") == type_id and row.get("class"): + return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」" + from B08_Quantity.B08_Quantity_Wording import type_label + + return None, ( + f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 " + "정하지 못했습니다 — 임의로 고르지 않습니다" + ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py new file mode 100644 index 00000000..37db599b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -0,0 +1,702 @@ +"""인계 줄을 만드는 자리 — 토공·운반·구조물·준비공·배수관·연장·타설 (`Engine_Handoff` 에서 갈라냄). + +⚠ **왜 갈랐나** — 위와 같다(700줄 제한). **줄의 모양은 하나도 안 바뀐다.** +⚠ **줄 빌더가 여덟이라 칸을 하나 늘리면 여덟 곳을 함께 고쳐야 한다** — 계약 시험 + (`tmp/tests/test_b08_handoff_contract.py`)이 「모든 줄이 같은 칸을 갖는가」로 그것을 지킨다. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + ORIGIN_EARTHWORK, + ORIGIN_HAUL, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_SLOPE, + ORIGIN_STRUCTURE, + SLOPE_GROUPS, + SUBTOTAL_GROUPS, + WorkItemMapping, + composite_quantities, + masonry_class, + placing_code, + structure_kind, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import ( + STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import ( + STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING +from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY +from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label + + +def _spec_detail(structure: dict[str, Any]) -> str: + """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" + parts: list[str] = [] + height = structure.get("height_m") + length = structure.get("length_m") + if height: + parts.append(f"H={height:g}") + if length: + parts.append(f"L={length:g}m") + return "·".join(parts) + + +def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: + """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. + + 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. + 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. + """ + if ground is None or ground == "토사": + return ground, "" + method = methods.get(ground) + mapped = METHOD_TO_GROUND.get(method or "") + if mapped: + return mapped, "" + return None, NOTE_METHOD_MISSING + + +def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: + """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. + + ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, + **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 + `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — + 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. + ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. + """ + declared = str((entry or {}).get("basis_unit") or "") + if not declared or not unit: + return None + if normalize_unit(unit) == normalize_unit(declared): + return None + return ( + declared, + str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), + str((entry or {}).get("mismatch_reason") or ""), + ) + + +def _earthwork_rows( + summary_table: dict[str, Any], + mapping: WorkItemMapping, + methods: dict[str, str | None], +) -> tuple[list[dict[str, Any]], list[str]]: + """토공집계표 줄을 내역 줄로 옮긴다. + + ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. + 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in summary_table.get("rows") or []: + group = str(row.get("group") or "") + if not group: + continue + ground = row.get("item") or None + origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK + is_subtotal = group in SUBTOTAL_GROUPS + lookup_ground, method_note = _mapping_ground(ground, methods) + entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None + code = (entry or {}).get("work_item_code") + # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). + # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. + unit = str(row.get("unit") or "㎥") + amount = float(row.get("amount") or 0.0) + mismatch = _basis_mismatch(entry, unit) if code else None + spec_detail = "" + if mismatch is not None: + spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" + unit, amount = mismatch[0], 0.0 + if code is None and not is_subtotal: + label = f"{group}({ground})" if ground else group + unmatched.append(f"{label} — {method_note}" if method_note else label) + rows.append( + { + "work_item_code": code, + "name": group, + "spec": str(row.get("spec") or ""), + "unit": unit, + "quantity": amount, + # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. + "quantity_gross": row.get("amount_gross"), + "application_ratio_pct": row.get("application_ratio_pct"), + # 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다. + "application_ratio_breakdown": row.get("application_ratio_breakdown"), + "quantity_breakdown": row.get("quantity_breakdown"), + "ground_class": ground, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "spec_detail": spec_detail, + "composite_parts": None, + "structure_kind": None, + # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": "", + # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. + "blocked_kind": mismatch[1] if mismatch else None, + "blocked_reason": mismatch[2] if mismatch else "", + "composite_not_ready": None, + # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. + "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, + "excavation_method": methods.get(ground) if ground else None, + "in_bill_reason": "집계 합계 줄 — 검산용" + if is_subtotal + else str(row.get("note") or ""), + "origin": origin, + } + ) + return rows, unmatched + + +def _haul_rows( + haul_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in haul_table.get("rows") or []: + equipment = str(row.get("equipment") or "") + entry = mapping.for_haul(equipment) or {} + code = entry.get("work_item_code") + in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) + if code is None and in_bill: + unmatched.append(f"운반({equipment})") + rows.append( + { + "work_item_code": code, + "name": f"{equipment} 운반", + "spec": str(row.get("ground") or ""), + "unit": "㎥", + "quantity": float(row.get("volume_m3") or 0.0), + # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": row.get("ground") or None, + "haul_distance_m": float(row.get("average_distance_m") or 0.0), + "haul_equipment": equipment, + "excavation_method": None, + "station_from": None, + "station_to": None, + "spec_detail": "", + "composite_parts": None, + "structure_kind": None, + # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": "", + "blocked_kind": None, + "blocked_reason": "", + "composite_not_ready": None, + "in_bill": in_bill, + "in_bill_reason": str(entry.get("reason") or ""), + "origin": ORIGIN_HAUL, + } + ) + return rows, unmatched + + +def blocked_of( + structure: dict[str, Any], class_basis: str = "", has_code: bool = False +) -> tuple[str | None, str]: + """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. + + ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. + 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. + + ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). + B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 + **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 + 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. + **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** + """ + notes = [str(note) for note in structure.get("notes") or []] + if has_code and float(structure.get("length_m") or 0.0) > 0: + return None, "" + if structure.get("components"): + # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). + if class_basis and "입력되지 않았습니다" in class_basis: + return BLOCKED_INPUT_MISSING, class_basis + return None, "" + for note in notes: + if "입력되지 않았습니다" in note: + return BLOCKED_INPUT_MISSING, note + if "자료에 없습니다" in note or "표준 물량 자료" in note: + return BLOCKED_UNIT_DATA_MISSING, note + if "산출식이 아직 없습니다" in note: + return BLOCKED_FORMULA_MISSING, note + if notes: + return BLOCKED_UNIT_DATA_MISSING, notes[0] + return None, "" + + +def _component_billing( + structure: dict[str, Any], entry: dict[str, Any] +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. + + ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × + √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. + ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 + 집는다. + """ + name = str(entry.get("billing_component") or "") + if not name: + return None + for component in structure.get("components") or []: + if str(component.get("name") or "") != name: + continue + unit = str(component.get("unit") or "") + amount = float(component.get("amount") or 0.0) + if unit and amount > 0: + return unit, amount + return None + + +def _structure_rows( + unit_quantity_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """구조물 줄 — **작업 공종 하나**로 선다. + + ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, + 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for structure in unit_quantity_table.get("structures") or []: + type_id = str(structure.get("type_id") or "") + entry = mapping.for_structure(type_id) or {} + code = entry.get("work_item_code") + # 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 — + # 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다). + class_key: str | None = None + class_basis = "" + if entry.get("class_from") == "bond": + bond = str((structure.get("options") or {}).get("bond") or "").strip() + bond_codes = entry.get("bond_codes") or {} + if bond in bond_codes: + # 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다. + code = bond_codes[bond] + class_key = bond + class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}" + else: + class_basis = ( + "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " + "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" + ) + if code and entry.get("class_from") == "back_length": + class_key, class_basis = masonry_class(structure.get("options") or {}) + + # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 + # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 + # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. + billing = _component_billing(structure, entry) + composite = mapping.composite_for(type_id) if code is None else None + kind = structure_kind(structure) if composite else None + parts: list[dict[str, Any]] | None = None + parts_missing: list[dict[str, Any]] = [] + if composite: + parts, parts_missing = composite_quantities(structure, composite, mapping) + # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — + # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. + counts_by_component = bool(entry.get("billing_component")) + blocked_kind, blocked_reason = blocked_of( + structure, + class_basis, + has_code=bool(code) and (billing is not None or not counts_by_component), + ) + # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. + variant_axis = str(entry.get("variant_axis") or "") or None + variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None + secondary_axes = [ + {"axis": axis, "value": (structure.get("options") or {}).get(axis)} + for axis in entry.get("secondary_axes") or [] + ] + if code is None and composite is None: + unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") + elif entry.get("class_from") in ("back_length", "bond") and class_key is None: + unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") + length = float(structure.get("length_m") or 0.0) + # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — + # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). + # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. + if billing is not None: + bill_unit, bill_quantity = billing + elif counts_by_component: + # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 + # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). + bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 + elif structure.get("billing_unit"): + bill_unit = str(structure["billing_unit"]) + bill_quantity = float(structure.get("billing_quantity") or 0.0) + else: + bill_unit, bill_quantity = "m", length + rows.append( + { + "work_item_code": code, + "name": str(structure.get("name") or type_id), + "spec": _spec_detail(structure), + "unit": bill_unit, + "quantity": bill_quantity, + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": structure.get("start_m"), + "station_to": structure.get("end_m"), + "excavation_method": None, + "spec_detail": _spec_detail(structure), + # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. + "composite_parts": parts, + # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. + "structure_kind": kind, + # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason, + # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. + # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). + # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 + # 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와 + # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. + "variant_axis": variant_axis, + "variant_value": variant_value, + # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. + # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). + "secondary_axes": secondary_axes or None, + "spec_class": class_key, + "spec_class_basis": class_basis, + # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. + "composite_not_ready": parts_missing or None, + "in_bill": True, + "in_bill_reason": (composite or {}).get("why", ""), + "origin": ORIGIN_STRUCTURE, + } + ) + return rows, unmatched + + +#: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다. +#: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다. +#: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는 +#: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다. +#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) +PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) + + +def _placing_rows( + unit_quantity_table: dict[str, Any], + mapping: WorkItemMapping, + method: str | None, +) -> tuple[list[dict[str, Any]], list[str]]: + """콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다. + + ⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고 + 재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가 + `B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다. + ⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다. + + ⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 — + 사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`). + """ + code, used_default = placing_code(mapping, method) + if code is None: + return [], [] + buckets: dict[str, float] = {} + for structure in unit_quantity_table.get("structures") or []: + # ⚠⚠ **묶음으로 서는 구조물은 건너뛴다 — 그 콘크리트는 묶음 조각이 이미 센다.** + # 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 들어 있다(`work_item_mapping` + # 의 `composite`). 여기서 또 세우면 **같은 콘크리트를 두 번** 센다. + # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — + # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** + # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. + if mapping.composite_for(str(structure.get("type_id") or "")): + continue + volume = sum( + float(component.get("amount") or 0.0) + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES + and component.get("unit") == "㎥" + ) + if volume <= 0: + continue + buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume + rows = [ + { + "work_item_code": code, + "name": "콘크리트 타설", + "spec": kind, + "unit": "㎥", + "quantity": volume, + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + "spec_detail": kind, + "composite_parts": None, + "structure_kind": kind, + "blocked_kind": None, + "blocked_reason": "", + "variant_axis": "structure_kind", + "variant_value": kind, + "secondary_axes": None, + "spec_class": kind, + "spec_class_basis": ( + "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" + ), + "composite_not_ready": None, + "in_bill": True, + "in_bill_reason": "", + "origin": ORIGIN_STRUCTURE, + } + for kind, volume in sorted(buckets.items()) + ] + notes: list[str] = [] + if rows and used_default: + notes.append( + "콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — " + "산출 조건에서 정하면 이 공종의 단가가 달라집니다" + ) + return rows, notes + + +def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: + """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. + + ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). + 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. + 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — + **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** + + ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 + 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. + """ + rows: list[dict[str, Any]] = [] + for row in preparation_table.get("rows") or []: + status = str(row.get("status") or "") + amount = row.get("amount") + ready = status == PREP_READY and amount is not None + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": str(row.get("item") or ""), + "spec": str(row.get("group") or ""), + "unit": str(row.get("unit") or ""), + "quantity": float(amount or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + "spec_detail": str(row.get("group") or ""), + "composite_parts": None, + "structure_kind": None, + # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. + "blocked_kind": None if ready else _prep_blocked_kind(status), + "blocked_reason": "" if ready else str(row.get("reason") or status), + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("reason") or ""), + "composite_not_ready": None, + # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("reason") or status), + "origin": ORIGIN_PREPARATION, + } + ) + return rows + + +def _prep_blocked_kind(status: str) -> str | None: + """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" + if status == PREP_PENDING: + return BLOCKED_INPUT_MISSING + if status == PREP_COUNTED_ELSEWHERE: + # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. + return None + if status == PREP_NOT_APPLICABLE: + return None + return BLOCKED_UNIT_DATA_MISSING + + +def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: + """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). + + ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 + (B09 ㉡ 가드와 같은 자리). + """ + rows: list[dict[str, Any]] = [] + for row in pipe_table.get("rows") or []: + ready = bool(row.get("in_bill")) + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": f"배수관({row.get('kind')})", + "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "unit": str(row.get("unit") or "m"), + "quantity": float(row.get("quantity") or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": row.get("chainage_m"), + "station_to": row.get("chainage_m"), + "excavation_method": None, + "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": row.get("blocked_kind"), + "blocked_reason": str(row.get("blocked_reason") or ""), + # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("blocked_reason") or ""), + "composite_not_ready": None, + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), + "origin": ORIGIN_PIPE, + } + ) + return rows + + +def _length_rows( + length_table: list[dict[str, Any]], mapping: WorkItemMapping +) -> list[dict[str, Any]]: + """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). + + ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** + 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. + 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 + **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). + + ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, + C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. + 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. + + ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** + """ + rows: list[dict[str, Any]] = [] + for entry in length_table or []: + type_id = str(entry.get("type_id") or "") + found = mapping.for_structure(type_id) + code = (found or {}).get("work_item_code") + length = float(entry.get("length_m") or 0.0) + raw = float(entry.get("raw_length_m") or length) + # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 + # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). + # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. + spans = [ + span + for span in (entry.get("spans") or []) + if span.get("start_m") is not None and span.get("end_m") is not None + ] + span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) + note = f"구간 {span_note}" if span_note else "" + if abs(raw - length) > 1e-9: + 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" + note = f"{note} · {겹침}" if note else 겹침 + # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 + # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). + reason = "" + if code is None: + reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" + elif length <= 0: + reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" + rows.append( + { + "work_item_code": code, + "name": str(entry.get("name") or type_id), + "spec": f"{entry.get('count')}개소", + "unit": "m", + "quantity": length, + "quantity_gross": raw if note else None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. + "station_from": spans[0]["start_m"] if spans else None, + "station_to": spans[-1]["end_m"] if spans else None, + "excavation_method": None, + "spec_detail": f"{entry.get('count')}개소", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, + "blocked_reason": reason, + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": note, + "composite_not_ready": None, + "in_bill": bool(code and length > 0), + "in_bill_reason": "" if (code and length > 0) else reason, + "origin": ORIGIN_STRUCTURE, + } + ) + return rows + + +def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: + """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" + rows: list[dict[str, Any]] = [] + for row in material_table.get("rows") or []: + rows.append( + { + "material_name": row.get("name"), + "spec": row.get("spec") or "", + "unit": row.get("unit"), + "net_amount": row.get("net_amount"), + "total_amount": row.get("total_amount"), + "surcharge_pct": row.get("surcharge_pct"), + "surcharge_note": row.get("note") or "", + "supply_type": row.get("supply"), + "install_by": row.get("install_by"), + "source_structure": row.get("sources") or [], + } + ) + return rows diff --git a/B08_Quantity/B08_Quantity_Engine_HaulSummary.py b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py new file mode 100644 index 00000000..5653ffc4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_HaulSummary.py @@ -0,0 +1,217 @@ +"""운반 가중평균 — 내역 줄이 되는 4줄과 그 근거 (B08 일감 5 · PLAN 8-3·8-7). + +무엇을 내나 + 실무는 **(운반수단 × 지반유형)별 가중평균 1개**를 내역에 올린다. 울진 실측 — + 「도자 토사 1,170㎥ 평균 43.66m · 도자 암 1,554㎥ 39.07m · 덤프 토사 1,667㎥ 293.78m · + 덤프 암 1,714㎥ 318.6m」로 **4줄**이다. 오솔길도 분류별 가중평균 1개를 낸다 + (거창 무대: 10,399 ÷ 871 = 11.94m). + + 개별 구간 줄은 버리지 않고 **근거**로 함께 낸다 — 어느 구간이 그 평균을 만들었는지 + 되짚을 수 있어야 한다. + +가중평균 = Σ(토량 × 거리) ÷ Σ(토량) + 실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식이다. 단순평균이 아니다. + +⚠ 무대(`free_haul`)는 내역 줄이 되지 않는다 (PLAN 8-7 ㉡) + 품셈 1-2-7 「소운반 20 m 이내는 품에 포함」. 켜도 붙일 단가가 품셈에 없다 — + 인력운반은 `10-6` 「소운반 20 m **초과분**」이다. 그래서 `in_bill=False` 로 표시해 넘기고 + 값은 검산(`무대+도자+덤프 = 총 운반토량`)에 쓴다. + +입력은 `HaulPlan` 이다 (이미 있는 값 — 다시 세지 않는다) + 띠(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)이 + 들어 있다. 떨어진 구간끼리 옮기는 `transfers` 도 같은 모양이라 함께 센다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다. +GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"} +# 무대 — 품에 포함이라 내역 줄이 되지 않는다. +FREE_HAUL_KEY = "free_haul" + + +@dataclass(slots=True) +class HaulLeg: + """근거 줄 하나 — 어느 구간을 얼마나 몇 m 옮겼나.""" + + equipment: str + ground: str + volume_m3: float + distance_m: float + from_m: float + to_m: float + source: str # `band` 또는 `transfer` + + +@dataclass(slots=True) +class HaulSummaryRow: + """내역 줄 — (운반수단 × 지반유형) 하나.""" + + equipment: str + ground: str + volume_m3: float = 0.0 + work_m3m: float = 0.0 # Σ(토량 × 거리) — 가중평균의 분자 + legs: int = 0 + in_bill: bool = True + + @property + def average_distance_m(self) -> float: + return self.work_m3m / self.volume_m3 if self.volume_m3 else 0.0 + + +def _legs_of(plan: dict[str, Any]) -> list[HaulLeg]: + """`HaulPlan` → 근거 줄 목록. 띠와 장거리 이동을 같은 모양으로 편다.""" + legs: list[HaulLeg] = [] + + def push( + item: dict[str, Any], + equipment: str | None, + distance: Any, + source: str, + from_m: Any, + to_m: Any, + ) -> None: + if not equipment or not isinstance(distance, (int, float)): + return + for key, label in GROUND_LABELS.items(): + volume = item.get(key) + if not isinstance(volume, (int, float)) or volume <= 0: + continue + legs.append( + HaulLeg( + equipment=str(equipment), + ground=label, + volume_m3=float(volume), + distance_m=float(distance), + from_m=float(from_m or 0.0), + to_m=float(to_m or 0.0), + source=source, + ) + ) + + for block in plan.get("blocks") or []: + for band in block.get("bands") or []: + push( + band, + band.get("equipment"), + band.get("haul_distance_m"), + "band", + band.get("haul_from_m"), + band.get("haul_to_m"), + ) + for transfer in plan.get("transfers") or []: + push( + transfer, + transfer.get("equipment"), + transfer.get("haul_distance_m"), + "transfer", + transfer.get("from_m"), + transfer.get("to_m"), + ) + return legs + + +def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]: + """(운반수단 × 지반유형)별 가중평균. 실무 내역이 이 줄들을 그대로 쓴다.""" + grouped: dict[tuple[str, str], HaulSummaryRow] = {} + for leg in legs: + key = (leg.equipment, leg.ground) + row = grouped.get(key) + if row is None: + row = HaulSummaryRow( + equipment=leg.equipment, + ground=leg.ground, + in_bill=leg.equipment != FREE_HAUL_KEY, + ) + grouped[key] = row + row.volume_m3 += leg.volume_m3 + row.work_m3m += leg.volume_m3 * leg.distance_m + row.legs += 1 + # 수단 → 지반유형 순으로 안정 정렬 — 화면·내역 줄 순서가 매번 같아야 한다. + order = {FREE_HAUL_KEY: 0, "dozer": 1, "dump_truck": 2} + labels = list(GROUND_LABELS.values()) + return sorted( + grouped.values(), + key=lambda row: ( + order.get(row.equipment, 9), + labels.index(row.ground) if row.ground in labels else 9, + ), + ) + + +def build_table(plan: dict[str, Any] | None) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다.""" + legs = _legs_of(plan or {}) + rows = summarize(legs) + return { + "method": "volume_weighted_average", + "rows": [ + { + "equipment": row.equipment, + "ground": row.ground, + "volume_m3": row.volume_m3, + "average_distance_m": row.average_distance_m, + "work_m3m": row.work_m3m, + "legs": row.legs, + "in_bill": row.in_bill, + } + for row in rows + ], + # 근거 — 어느 구간이 그 평균을 만들었나. 내역에는 안 오른다. + "legs": [ + { + "equipment": leg.equipment, + "ground": leg.ground, + "volume_m3": leg.volume_m3, + "distance_m": leg.distance_m, + "from_m": leg.from_m, + "to_m": leg.to_m, + "source": leg.source, + } + for leg in legs + ], + "bill_row_count": sum(1 for row in rows if row.in_bill), + } + + +def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]: + """토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.""" + return [ + { + "equipment": row["equipment"], + "ground": row["ground"], + "volume_m3": row["volume_m3"], + "average_distance_m": row["average_distance_m"], + } + for row in table.get("rows") or [] + ] + + +@dataclass(slots=True) +class HaulCheck: + """검산 — 무대를 안 내면 이 대조가 죽는다(PLAN 8-7 ㉡).""" + + hauled_total_m3: float = 0.0 + plan_total_m3: float = 0.0 + difference_m3: float = 0.0 + details: dict[str, float] = field(default_factory=dict) + + +def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck: + """`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가.""" + hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or []) + plan = plan or {} + planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0) + by_equipment: dict[str, float] = {} + for row in table.get("rows") or []: + key = str(row.get("equipment")) + by_equipment[key] = by_equipment.get(key, 0.0) + float(row.get("volume_m3") or 0.0) + return HaulCheck( + hauled_total_m3=hauled, + plan_total_m3=planned, + difference_m3=hauled - planned, + details=by_equipment, + ) diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py new file mode 100644 index 00000000..c8283641 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -0,0 +1,337 @@ +"""자재 총괄표 — 할증이 붙는 **유일한** 자리 (B08 일감 7 · PLAN 8-2·8-3·8-7). + +여기가 하는 일은 하나다 + 구조물 전개(`..._Engine_UnitQuantity`)가 낸 성분 가운데 **`destination == "material"`** + 인 것만 모아, 자재별로 합치고 **할증률을 한 번** 붙인다. 열은 순수량·할증률·합계 셋이고 + **금액은 없다**(금액은 B09 몫, 8-2 경계). + +⚠⚠ ㉠ 이중계상 방어 — 할증은 여기 한 번뿐이다 + 원단위표(`surcharge_applied: False`)도 B09 일위대가 재료비도 **할증 전** 값이다. + 두 곳에서 붙이면 자재가 두 번 부푼다. `verify_single_surcharge()` 가 입력 표의 + 깃발을 실제로 읽어 막는다 — 규칙이 주석에만 있으면 지켜지지 않는다. + +⚠ `earthwork`·`unit_price` 는 여기 오지 않는다 + 터파기·되메우기는 토공집계로, 모르터·돌쌓기는 B09 일위대가로 간다. 섞이면 그게 곧 + 이중계상이다. 걸러 낸 성분은 버리지 않고 `skipped_by_destination` 으로 세어 보인다. + +⚠ 할증률을 코드에 박지 않는다 (요율과 같은 취급) + 값은 `resources/data_material_surcharge/material_surcharge_<판>.json` 에 있다. + 표에 없는 자재는 **0 % 로 조용히 넘기지 않는다** — 「할증률 미확보」로 드러낸다. + 0 % 로 넘기면 빠뜨린 것과 구별이 안 된다. + +⚠ 품셈에 이미 할증이 포함된 항목은 제외한다 (품셈 1-3-1 단서) + 「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면 + (`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다. + +⚠ 관급구분은 **세 값**이다 — `owner_supplied` · `contractor_supplied` · `unknown` + `unknown` 은 「아직 안 정함」이고 **지어내지 않겠다는 뜻**이다. B09 는 이 줄을 관급자재대에도 + 도급 재료비에도 넣지 않고 `missing` 으로 뺀다(2026-09-07 계약에 명시). + +⚠ 관급/사급은 **법이 아니라 발주 결정**이다 + 자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정 + (`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨 + 화면에 드러낸다. 구분 이름은 B09 와 같은 낱말을 쓴다 — 다르면 인계에서 어긋난다. + 관급 줄에는 **설치 주체**(`install_by`)가 하나 더 붙는다 — 안전관리비 대상액이 + 관급 전액이 아니라 「도급자설치 관급금액」이기 때문이다. + +⚠ 자재 이름은 **정확히 일치**로만 찾는다 + 부분일치로 재면 `막자갈`(뒤채움)이 `자갈` 할증을 물게 된다 — 원단위 엔진에서 이미 + 한 번 겪은 자리다. 못 찾으면 지어내지 말고 「할증률 미확보」로 드러낸다. + +⚠ 할증 전/후 값을 **둘 다** 남긴다 (8-2 인계 6필드) + 하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. `net_amount`(전) 와 + `total_amount`(후) 를 나란히 둔다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +from common_util.common_util_quantity_spread import spread_by_unit + +# ── 데이터 자리 ────────────────────────────────────────────────────── +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_material_surcharge" +DATASET_PREFIX = "material_surcharge_" + +#: 이 표가 받는 성분 갈래. 나머지는 각자 다른 표로 간다. +ACCEPTED_DESTINATION = "material" + +#: 관급/사급 구분 — **데이터 값은 영문 키, 한글은 화면 표기용**(2026-09-07 B09 와 확정). +#: B09 원가 엔진의 `owner_supplied_material_krw`(⑤ 관급자재대)와 같은 낱말이라 그대로 이어진다. +SUPPLY_OWNER = "owner_supplied" # 관급 — 발주처 지급 +SUPPLY_CONTRACTOR = "contractor_supplied" # 사급 — 도급자 구입 +SUPPLY_UNKNOWN = "unknown" # 아직 안 정함 — 화면에 드러낸다 +SUPPLY_LABELS = {SUPPLY_OWNER: "관급", SUPPLY_CONTRACTOR: "사급", SUPPLY_UNKNOWN: "미분류"} + +#: ⚠ 관급 안의 **설치 주체** — 안전관리비 대상액은 관급 전액이 아니라 「도급자설치 관급금액」이다 +#: (PLAN 8-10 대상액 정의). 관급/사급 두 갈래로만 두면 B09 가 ⑤를 못 세운다. +#: **관급 줄에만 붙이고 사급 줄은 비운다.** 모르면 기본값으로 때우지 않고 `None` 으로 둔다 — +#: 잘못 찍으면 안전관리비가 조용히 틀린다. +INSTALL_BY_CONTRACTOR = "contractor" # 도급자설치 +INSTALL_BY_OWNER = "owner" # 관 직접설치 +INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"} +NOTE_INSTALL_BY_MISSING = "설치 주체 미지정" + +#: ⚠ 할증 상태는 **세 갈래**다 (2026-09-07 3자 계약 정정). +#: 두 갈래(`True`/`False`)로 두면 「율을 못 찾아 안 붙인 것」이 「붙였다」로 나가고, +#: 나중에 진짜 율이 들어왔을 때 B09 가 한 번 더 붙인다. **깃발과 실제가 어긋나지 않을 것**이 +#: 요건이므로 상태를 그대로 말한다. +SURCHARGE_APPLIED = "applied" # 한 줄이라도 실제로 붙음 +SURCHARGE_NOT_APPLIED = "not_applied" # 붙일 줄이 없음(자재 자체가 없음) +SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 못 찾음 + +NOTE_RATE_MISSING = "할증률 미확보" +NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함" + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class SurchargeTable: + """할증률표 한 판. 조건이 갈리는 자재는 `alt_rate` 를 같이 들고 있는다.""" + + effective_date: str = "" + source: dict[str, Any] = field(default_factory=dict) + rates: dict[str, dict[str, Any]] = field(default_factory=dict) + + def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]: + """(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**""" + entry = self.rates.get(material.strip()) + if entry is None: + return None, "" + alt_condition = entry.get("alt_condition") + if condition and alt_condition and condition == alt_condition: + return float(entry["alt_rate"]), material + "(" + str(alt_condition) + ")" + base_condition = entry.get("condition") + label = material + "(" + str(base_condition) + ")" if base_condition else material + return float(entry["rate"]), label + + @property + def material_names(self) -> list[str]: + return sorted(self.rates) + + +def load_surcharge_table(path: Path | None = None) -> SurchargeTable: + """할증률표를 읽는다. 파일이 없으면 **빈 표** — 전 자재가 「미확보」로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return SurchargeTable() + payload = json.loads(target.read_text(encoding="utf-8")) + rates = { + str(row["material"]).strip(): row + for row in payload.get("rates_pct", []) + if row.get("material") is not None and row.get("rate") is not None + } + return SurchargeTable( + effective_date=str(payload.get("effective_date") or ""), + source=payload.get("source") or {}, + rates=rates, + ) + + +@dataclass +class MaterialRow: + """총괄표 한 줄. 할증 **전·후를 둘 다** 들고 있는다(8-2 인계).""" + + name: str + unit: str + net_amount: float = 0.0 # 순수량 — 할증 전 + surcharge_pct: float | None = None # None = 미확보 + supply: str = SUPPLY_UNKNOWN + install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다 + surcharge_included: bool = False # 품셈에 이미 포함 + basis: str = "" + sources: list[str] = field(default_factory=list) + + @property + def total_amount(self) -> float: + """합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다.""" + if self.surcharge_included or self.surcharge_pct is None: + return self.net_amount + return self.net_amount * (1.0 + self.surcharge_pct / 100.0) + + @property + def note(self) -> str: + parts: list[str] = [] + if self.surcharge_included: + parts.append(NOTE_INCLUDED) + elif self.surcharge_pct is None: + parts.append(NOTE_RATE_MISSING) + elif self.basis: + parts.append(self.basis) + if self.supply == SUPPLY_OWNER and self.install_by is None: + parts.append(NOTE_INSTALL_BY_MISSING) + return " · ".join(parts) + + +def _surcharge_status(rows: list[MaterialRow]) -> str: + """할증이 실제로 붙었는가 — 세 갈래로 답한다.""" + if not rows: + return SURCHARGE_NOT_APPLIED + if any(row.surcharge_pct is not None and not row.surcharge_included for row in rows): + return SURCHARGE_APPLIED + return SURCHARGE_RATE_UNAVAILABLE + + +def _supply_of(value: Any) -> tuple[str, str | None]: + """설정 한 칸을 (관급구분, 설치주체) 로 읽는다. + + 설정은 두 모양을 받는다 — 구분만 적은 `"owner_supplied"` 와 설치 주체까지 적은 + `{"supply": ..., "install_by": ...}`. 앞 모양으로 적힌 관급은 **설치 주체 미지정**이 되고 + 그대로 드러난다. 기본값으로 때우지 않는다 — 잘못 찍으면 안전관리비가 조용히 틀린다. + """ + if isinstance(value, dict): + supply = str(value.get("supply") or SUPPLY_UNKNOWN) + install_by = value.get("install_by") + install_by = str(install_by) if install_by else None + else: + supply = str(value) if value else SUPPLY_UNKNOWN + install_by = None + if supply != SUPPLY_OWNER: + install_by = None # 사급 줄은 비워 둔다 + return supply, install_by + + +def verify_single_surcharge(unit_quantity_table: dict[str, Any] | None) -> list[str]: + """⚠ 앞 단계가 이미 할증을 붙였으면 알린다 (㉠ 방어). + + 원단위표는 `surcharge_applied: False` 로 「할증 전」임을 못 박아 보낸다. 그 깃발이 + 참이면 여기서 또 붙일 수 없다 — **조용히 건너뛰지 않고 알린다**. 말없이 넘기면 + 어느 쪽이 적용됐는지 아무도 모른다. + """ + if not unit_quantity_table: + return [] + if unit_quantity_table.get("surcharge_applied"): + return ["앞 단계(구조물 원단위)가 이미 할증을 붙였음 — 자재총괄에서 중복 적용 위험"] + return [] + + +def _collect( + unit_quantity_table: dict[str, Any], +) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]: + """`destination == "material"` 만 모은다. 나머지는 세어서 보인다.""" + rows: dict[tuple[str, str], MaterialRow] = {} + skipped: dict[str, int] = {} + for structure in unit_quantity_table.get("structures", []): + label = str(structure.get("name") or structure.get("type_id") or "") + for component in structure.get("components", []): + destination = str(component.get("destination") or "") or "(없음)" + if destination != ACCEPTED_DESTINATION: + skipped[destination] = skipped.get(destination, 0) + 1 + continue + name = str(component.get("name") or "").strip() + unit = str(component.get("unit") or "").strip() + row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit)) + row.net_amount += float(component.get("amount") or 0.0) + if component.get("surcharge_included"): + row.surcharge_included = True + if label and label not in row.sources: + row.sources.append(label) + return rows, skipped + + +def build_table( + unit_quantity_table: dict[str, Any], + *, + surcharge_table: SurchargeTable | None = None, + supply_map: dict[str, Any] | None = None, + extra_materials: Iterable[dict[str, Any]] = (), +) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. + + `extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다. + 모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`). + """ + table = surcharge_table or load_surcharge_table() + rows, skipped = _collect(unit_quantity_table) + + for item in extra_materials: + if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION: + continue + name = str(item.get("name") or "").strip() + unit = str(item.get("unit") or "").strip() + row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit)) + row.net_amount += float(item.get("amount") or 0.0) + if item.get("surcharge_included"): + row.surcharge_included = True + source = str(item.get("source") or "") + if source and source not in row.sources: + row.sources.append(source) + + supply = supply_map or {} + missing_rate: list[str] = [] + missing_supply: list[str] = [] + missing_install_by: list[str] = [] + for (name, _unit), row in rows.items(): + row.supply, row.install_by = _supply_of(supply.get(name)) + if row.supply == SUPPLY_UNKNOWN: + missing_supply.append(name) + # ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다. + if row.supply == SUPPLY_OWNER and row.install_by is None: + missing_install_by.append(name) + if row.surcharge_included: + continue + rate, basis = table.rate_for(name) + row.surcharge_pct = rate + row.basis = basis + if rate is None: + missing_rate.append(name) + + ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit)) + return { + "columns": [ + "자재명", + "단위", + "순수량", + "할증률(%)", + "합계", + "관급구분", + "설치주체", + "비고", + ], + "rows": [ + { + "name": row.name, + "unit": row.unit, + "net_amount": row.net_amount, + "surcharge_pct": row.surcharge_pct, + "total_amount": row.total_amount, + "supply": row.supply, + "supply_label": SUPPLY_LABELS.get(row.supply, row.supply), + "install_by": row.install_by, + "install_by_label": INSTALL_BY_LABELS.get(row.install_by or "", ""), + "note": row.note, + "sources": row.sources, + } + for row in ordered + ], + # ⚠ **깃발이 실제와 어긋나지 않게** 한다. 「붙일 자리였는데 율이 없어 못 붙였다」를 + # 「붙였다」로 말하면, 나중에 율이 들어왔을 때 B09 가 한 번 더 붙인다. + "surcharge_status": _surcharge_status(ordered), + # 옛 두 갈래 깃발 — **실제로 붙었을 때만** 참이다(호환을 위해 남긴다). + "surcharge_applied": _surcharge_status(ordered) == SURCHARGE_APPLIED, + "surcharge_dataset": { + "effective_date": table.effective_date, + "source": table.source, + }, + "missing_rate_materials": sorted(set(missing_rate)), + "missing_supply_materials": sorted(set(missing_supply)), + "missing_install_by_materials": sorted(set(missing_install_by)), + "double_count_warnings": verify_single_surcharge(unit_quantity_table), + "skipped_by_destination": skipped, + "row_count": len(ordered), + # 값의 크기가 말이 되나 — 자릿수 어긋남은 사람이 훑어야 보인다(단위별로 가른다). + "amount_spread": spread_by_unit( + [{"unit": row.unit, "amount": row.total_amount} for row in ordered], + value_key="amount", + ), + } diff --git a/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py new file mode 100644 index 00000000..1069c131 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_ObservedUnit.py @@ -0,0 +1,192 @@ +"""콘크리트 구조물 — **관측 원단위표** 조회 (B08 일감 ⑩ · PLAN 8-6·8-8). + +왜 전개식이 아니라 관측값인가 + 지식DB 가 못 박아 둔 사실이다 — **구조물별 표준 물량표는 품셈에 없다** + (`구조물_수량.md` 마지막 줄 · `배수공_수량.md` §2). 콘크리트 구조물의 물량은 + **설계 표준도**에서 나오는데 그 표준도가 원문(법·품셈)에 없다. 그래서 옹벽·집수정처럼 + 치수가 표준화된 것은 **실무 설계원본에서 뽑은 관측값**이 유일한 원천이다. + +두 근거가 한 표에 섞인다 — 그래서 줄마다 `basis` 를 단다 + · `derived` — 저장된 치수에서 **식으로** 나온 값(돌쌓기 계열, `..._Engine_UnitQuantity`). + · `observed` — 실무 관측 원단위표에서 **규격을 맞춰 꺼낸** 값(이 모듈). + 섞어 두고 근거를 안 적으면, 나중에 「이 값이 왜 이런가」를 아무도 못 되짚는다. + +⚠⚠ **보간하지 않는다** + 관측값은 **그 규격에서만** 맞다. `반중력식 H=2.0` 의 콘크리트 1.35 ㎥/m 를 H=1.6 으로 + 줄여 쓰면 틀린다 — 기초·벽 두께는 높이에 비례하지 않는다. 규격이 표에 없으면 + **「원단위 미확보」로 드러낸다.** 가까운 값을 갖다 쓰는 길을 두지 않는다. + +⚠ 치수를 지어내지 않는다 + BOX암거는 `structures.json` 에 **벽·저판·상판 두께가 없어** 전개식조차 못 세운다. + 두께를 가정하면 그 값이 콘크리트·거푸집·철근으로 **번져 나간다**. 미확보로 낸다. + +⚠ 이중계상 규칙은 그대로다 + ㉢ 배합을 분해하지 않는다(콘크리트 ㎥·모르터 ㎥ 까지). ㉠ 할증은 자재총괄 한 곳뿐. + 터파기·되메우기·잔토는 `destination: earthwork` 로 토공에 합산된다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_structure_unit" +DATASET_PREFIX = "structure_unit_observed_" + +#: 값이 어디서 왔나 — 한 표에 섞이므로 줄마다 단다. +BASIS_DERIVED = "derived" # 저장된 치수에서 식으로 +BASIS_OBSERVED = "observed" # 실무 관측 원단위표에서 + +NOTE_UNIT_MISSING = "원단위 미확보" + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +def _same(left: Any, right: Any) -> bool: + """규격 한 칸 비교. 숫자는 값으로, 나머지는 글자로 **정확히** 본다. + + `"800"` 과 `800` 은 같게 보되(입력 폼이 문자열을 준다), `2.0` 과 `1.6` 은 다르다 — + 가까운 값을 같다고 보는 길은 두지 않는다. + """ + if isinstance(left, (int, float)) and not isinstance(left, bool): + try: + return abs(float(left) - float(right)) < 1e-9 + except (TypeError, ValueError): + return False + return str(left).strip() == str(right).strip() + + +@dataclass +class ObservedUnitTable: + """관측 원단위표 한 판.""" + + effective_date: str = "" + entries: list[dict[str, Any]] = field(default_factory=list) + sources: dict[str, Any] = field(default_factory=dict) + not_found: dict[str, Any] = field(default_factory=dict) + #: 값을 바꾸는 **설계 조건**인데 우리 제원에 칸이 없는 것 — 화면이 보이게 한다. + pending_choices: dict[str, Any] = field(default_factory=dict) + + def find(self, type_id: str, spec: dict[str, Any]) -> dict[str, Any] | None: + """규격이 **모두** 맞는 줄만 돌려준다. 하나라도 어긋나면 없는 것으로 본다.""" + for entry in self.entries: + if entry.get("type_id") != type_id: + continue + wanted = entry.get("spec") or {} + if all(key in spec and _same(value, spec[key]) for key, value in wanted.items()): + return entry + return None + + def specs_for(self, type_id: str) -> list[dict[str, Any]]: + """그 종류로 표에 있는 규격 목록 — 「무엇이 있는지」를 화면이 보이게.""" + return [ + entry.get("spec") or {} for entry in self.entries if entry.get("type_id") == type_id + ] + + +def load_observed_table(path: Path | None = None) -> ObservedUnitTable: + """관측 원단위표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return ObservedUnitTable() + payload = json.loads(target.read_text(encoding="utf-8")) + return ObservedUnitTable( + effective_date=str(payload.get("effective_date") or ""), + entries=list(payload.get("entries") or []), + sources=payload.get("sources") or {}, + not_found=payload.get("not_found") or {}, + pending_choices=payload.get("pending_choices") or {}, + ) + + +def scale_for(entry: dict[str, Any], structure: dict[str, Any]) -> tuple[float, str]: + """관측값에 곱할 수 — 단위가 `m` 면 연장, `㎡` 면 면적, `개소` 면 1. + + ⚠ **규격을 늘리는 것이 아니라 개수를 세는 것**이다. `H=2.0 옹벽 10m` 는 같은 단면이 + 10m 이어진 것이라 곱해도 되지만, `H=1.6` 으로 바꾸는 것은 단면이 달라지므로 안 된다. + """ + options = structure.get("options") or {} + unit = str(entry.get("unit") or "개소") + if unit == "m": + length = options.get("length_m") + if length is None: + start, end = structure.get("start_m"), structure.get("end_m") + length = ( + abs(float(end) - float(start)) if start is not None and end is not None else 0.0 + ) + return float(length or 0.0), f"연장 {float(length or 0.0):g} m" + if unit == "㎡": + width = options.get("ford_width_m") + length = options.get("length_m") or 0.0 + area = float(width or 0.0) * float(length or 0.0) + return area, f"면적 {area:g} ㎡" + return 1.0, "1 개소" + + +def billing_of( + type_id: str, + spec: dict[str, Any], + structure: dict[str, Any], + table: ObservedUnitTable | None = None, +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량). 표에 없으면 `None`. + + ⚠ **왜 있나** — 관측 원단위는 「개소당」·「㎡당」으로도 온다. 그런데 인계 줄이 늘 + 「m · 연장」으로 나가고 있어, **집수정 한 개소가 「연장 2m」면 값이 두 배로 실렸다** + (2026-09-08 ㉕ 실증에서 드러남). 성분은 개소 기준으로 맞게 서는데 **줄의 축만 + 어긋나** 있어서 아무 시험도 안 잡았다. + """ + found = (table or load_observed_table()).find(type_id, spec) + if found is None: + return None + scale, _note = scale_for(found, structure) + return str(found.get("unit") or "개소"), scale + + +def expand_observed( + type_id: str, + spec: dict[str, Any], + structure: dict[str, Any], + table: ObservedUnitTable | None = None, +) -> tuple[list[dict[str, Any]], list[str]]: + """(성분 목록, 알림). 규격이 표에 없으면 **빈 목록 + 미확보 알림**을 낸다.""" + from B08_Quantity.B08_Quantity_Wording import spec_missing + + found = (table or load_observed_table()).find(type_id, spec) + if found is None: + known = (table or load_observed_table()).specs_for(type_id) + # ⚠ 키 이름을 화면에 내보내지 않는다 — 사용자는 `retaining_wall` 을 모른다. + return [], [spec_missing(type_id, known)] + + scale, scale_note = scale_for(found, structure) + if scale <= 0: + from B08_Quantity.B08_Quantity_Wording import type_label + + return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"] + + source_key = str(found.get("source") or "") + components: list[dict[str, Any]] = [] + for item in found.get("components") or []: + note = str(item.get("basis_note") or "") + components.append( + { + "name": item["name"], + "unit": item["unit"], + "amount": float(item["amount"]) * scale, + "destination": item.get("destination") or "material", + # 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다. + "basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}" + + (f" ({note})" if note else ""), + "basis_kind": BASIS_OBSERVED, + "source": source_key, + } + ) + return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"] diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py new file mode 100644 index 00000000..9261086c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -0,0 +1,172 @@ +"""배수관 물량 — **정본 셋을 잇기만 한다** (2026-09-08 두 창 합의). + +값이 어디서 오나 + 관 자체(있나·어디·관경·관종) → `pipe_points.json` (레지스트리 `pipe` 타입이 + `managed_by: pipe_points`) + 관 연장(m) → 측점 `design.pipe_length_m` + (B06 횡단이 **서버 Node 로** 계산해 m 단위 올림까지 + 끝낸 값을 정본에 남긴다 — 계산이 두 벌이 아니다) + 관종 → 공종코드 → `work_item_mapping` 의 `pipe.kind_codes` + +⚠ **여기서 길이를 짓지 않는다.** 앞서 「도로폭 = 관 길이」처럼 잡을 뻔했는데 그것이 곧 + 임의 수치다. 연장이 없는 관은 **줄을 세우되 막힌 사유와 함께** 보낸다. + +⚠ **`facility` 가 `pipe` 인 점만 배관이다.** + `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이라 BOX암거·물넘이·세월교·독립 + 기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다** + (실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`). + +⚠ **유출·유입부 기슭막이는 여기서 세지 않는다.** + 관 옵션(`outlet_revet_*`)이 정본이고 구조물 목록에서는 빠졌다(2026-08-28 이관). + 구조물 쪽으로 또 세면 이중계상이다. + +⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.** + 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리). +""" + +from __future__ import annotations + +from typing import Any + +#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다. +FACILITY_PIPE = "pipe" + +#: 연장을 못 찾은 줄의 막힌 갈래 — 「입력하면 풀림」이 아니라 **앞 단계가 내야 하는 값**이다. +BLOCKED_LENGTH_MISSING = "input_missing" + +#: ⚠ **둘을 갈라 말한다** — 「[저장]을 누르면 풀리는 것」과 「눌러도 안 풀리는 것」은 +#: 사용자가 할 일이 다르다. 뒤엣것에 앞 문구를 쓰면 눌러 보고 안 되어 헤맨다 +#: (2026-09-08 실측: 관 9개 중 3개가 **횡단 행 자체가 없는** 자리였다). +NOTE_LENGTH_MISSING = ( + "관 연장이 아직 정본에 없습니다 — 횡단설계에서 [저장]을 한 번 누르면 " + "그 측점의 관 길이가 남고 값이 섭니다" +) +NOTE_SECTION_MISSING = ( + "그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 " + "[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다" +) +#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면 +#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다. +SECTION_MATCH_TOLERANCE_M = 0.05 + +NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다" +NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다" + + +def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float, float]: + """측점별 관 길이. **없는 측점은 담지 않는다** — 0 으로 채우면 「없음」과 구별이 안 된다.""" + found: dict[float, float] = {} + for row in designs or []: + design = row.get("design") if isinstance(row, dict) else None + if not isinstance(design, dict): + continue + value = design.get(key) + if value is None: + continue + try: + length = float(value) + except (TypeError, ValueError): + continue + if length > 0: + found[round(float(row.get("chainage_m") or 0.0), 3)] = length + return found + + +def _nearest(lengths: dict[float, float], chainage: float, tolerance: float = 0.5) -> float | None: + """관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다. + + ⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다. + """ + if not lengths: + return None + key = round(chainage, 3) + if key in lengths: + return lengths[key] + best = min(lengths, key=lambda x: abs(x - chainage)) + return lengths[best] if abs(best - chainage) <= tolerance else None + + +def build_rows( + pipe_points: list[dict[str, Any]], + designs: list[dict[str, Any]], + mapping: dict[str, Any] | None = None, + section_chainages: list[float] | None = None, +) -> dict[str, Any]: + """관 줄 목록. **값이 서는 줄도, 못 서는 줄도** 함께 낸다. + + `pipe_points` 는 `PipePoint.model_dump()` 또는 같은 모양의 딕셔너리 목록이다. + """ + table = mapping or {} + kind_codes: dict[str, str] = table.get("kind_codes") or {} + kind_key = str(table.get("kind_option_key") or "pipe_kind") + default_kind = str(table.get("default_kind") or "") + length_key = str(table.get("length_key") or "pipe_length_m") + diameter_key = str(table.get("diameter_option_key") or "pipe_diameter_mm") + + lengths = _length_by_chainage(designs, length_key) + # 횡단이 **있는데 길이가 없는 것**과 **관 자리에 횡단이 없는 것**을 가르기 위한 목록. + # 안 주면 종전대로 「[저장]하면 풀림」 하나로만 말한다. + sections = [float(x) for x in (section_chainages or [])] + rows: list[dict[str, Any]] = [] + notes: list[str] = [] + + for point in pipe_points or []: + if str(point.get("facility") or FACILITY_PIPE) != FACILITY_PIPE: + continue # 배관이 아닌 시설 — 그쪽 줄은 그쪽이 센다 + options = point.get("options") or {} + chainage = float(point.get("chainage_m") or 0.0) + + stored_kind = str(options.get(kind_key) or "").strip() + kind = stored_kind or default_kind + code = kind_codes.get(kind) + kind_note = "" + if not stored_kind and default_kind: + kind_note = NOTE_KIND_DEFAULT.format(kind=default_kind) + elif stored_kind and code is None: + kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind) + + length = _nearest(lengths, chainage) + # ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다. + # 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로 + # 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다. + # 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤). + has_section = not sections or any( + abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections + ) + blocked_kind = None if length else BLOCKED_LENGTH_MISSING + blocked_reason = "" + if not length: + blocked_reason = NOTE_LENGTH_MISSING if has_section else NOTE_SECTION_MISSING + if code is None: + blocked_kind = blocked_kind or BLOCKED_LENGTH_MISSING + blocked_reason = blocked_reason or kind_note + + rows.append( + { + "chainage_m": chainage, + "work_item_code": code, + "kind": kind, + "kind_from_default": not stored_kind, + # 갈래는 **저장 원본값**만 보낸다 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": diameter_key, + "variant_value": options.get(diameter_key), + "unit": str(table.get("unit") or "m"), + "quantity": float(length or 0.0), + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason or kind_note, + "in_bill": bool(length and code), + } + ) + if kind_note and kind_note not in notes: + notes.append(kind_note) + + missing = sum(1 for row in rows if not row["in_bill"]) + if missing: + notes.append(f"관 {len(rows)}개 중 {missing}개가 아직 값이 안 섭니다") + return { + "rows": rows, + "notes": notes, + "pipe_count": len(rows), + "ready_count": sum(1 for row in rows if row["in_bill"]), + "length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3), + } diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py new file mode 100644 index 00000000..ba97fa77 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -0,0 +1,266 @@ +"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (B08 일감 ⑪ · PLAN 8-3). + +8-3 대응표에서 ❌ 로 남아 있던 둘이다. 여기서 하는 일은 **줄을 세우고, 설 수 있는 줄은 +값을 채우고, 못 서는 줄은 왜 못 서는지 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과 +「원래 없는 것」이 구별되지 않는다. + +⚠⚠ 지장목제거와 겹치지 않는다 (이중계상) + 벌목·지장목제거는 **이미 토공집계의 사면 계열로 서 있다**(`tree_removal_*` × 반영률). + 여기서 또 세우면 같은 나무를 두 번 벤다. 그래서 준비공의 벌목 줄은 **값을 내지 않고 + 「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다. + +⚠ 값이 없는 줄의 사유를 적는다 + · 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**. + · 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다. + · 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다. + +⚠ 사방공은 이 노선에 실물이 없으면 「해당 없음」이다 + 있는 것처럼 0 을 적지 않는다. 구조물 목록에 사방 시설이 서면 그때 값이 선다. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from B08_Quantity.B08_Quantity_Wording import type_label + +#: 사방 시설로 보는 구조물 종류 — **레지스트리의 실제 `type_id` 를 쓴다**(D 그룹 + 흙막이). +#: 목록에 없으면 그 노선에 사방공이 **없는** 것이다. 이름을 지어내면 영영 안 걸린다. +EROSION_CONTROL_TYPES = frozenset( + { + "erosion_check", # 골막이 + "bed_sill", # 바닥막이 + "check_dam_small", # 소형사방댐(복합형) + "revetment", # 기슭막이 + "soil_guard", # 흙막이 + } +) + +#: 규준틀 — **품셈 원문이 개소 기준을 정해 둔다**(2026-09-07 ㉒ 에서 찾음). +#: 11-2 [주]① 「비탈길이 **10m 이상** **20m마다** 설치한다」 +#: 11-3 [주]① 「중심점에서 **성토 높이 5m 이상**에 설치한다」 +#: ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**로 둔다. +BATTER_MIN_SLOPE_LENGTH_M = 10.0 +BATTER_INTERVAL_M = 20.0 +LEVEL_MIN_FILL_HEIGHT_M = 5.0 + +STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" +STATUS_PENDING = "값을 낼 근거가 없음" +STATUS_NOT_APPLICABLE = "해당 없음" +STATUS_READY = "값 있음" + + +def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]: + """비탈 규준틀 개소 — **비탈길이 10m 이상인 구간에서 20m마다**(품셈 11-2 [주]①). + + ⚠ 「10m 이상」은 **비탈길이**(사면길이) 조건이고 「20m마다」는 **노선 거리** 간격이다. + 둘을 섞지 않는다 — 사면이 긴 구간의 **연장**을 20m 로 나눈다. + """ + length_m = 0.0 + notes: list[str] = [] + for row in slope_rows: + lengths = row.get("lengths") or {} + # 그 측점의 사면길이는 계열마다 있으나 **가장 긴 것**으로 본다(같은 사면이다). + longest = max((float(v) for v in lengths.values()), default=0.0) + if longest >= BATTER_MIN_SLOPE_LENGTH_M: + length_m += float(row.get("distance_m") or 0.0) + if length_m <= 0: + return 0, ["비탈길이 10m 이상인 구간이 없어 비탈 규준틀이 서지 않음"] + count = int(length_m // BATTER_INTERVAL_M) + 1 + notes.append( + f"비탈길이 {BATTER_MIN_SLOPE_LENGTH_M:g}m 이상 구간 {length_m:g}m ÷ " + f"{BATTER_INTERVAL_M:g}m + 1 (품셈 11-2 [주]①)" + ) + return count, notes + + +def level_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int | None, list[str]]: + """수평 규준틀 — **성토고 5m 이상 측점마다** 한 개소(품셈 11-3 [주]①). + + ⚠ 「비탈길이 10m 이상 20m마다」인 비탈규준틀과 **기준이 다르다** — 이쪽은 **지점 조건**이라 + 간격이 없다. 두 기준을 같은 식으로 쓰면 조용히 틀린다. + ⚠ 성토고 칸이 아예 없으면 **0 으로 때우지 않고** 미확보로 둔다 — 「없음」과 다르다. + """ + rows = list(slope_rows) + if not rows: + return None, ["사면표가 없어 성토고를 못 봄"] + if all("fill_height_m" not in row for row in rows): + return None, ["성토고가 사면표에 없어 개소를 못 셈 (품셈 11-3 [주]① 「성토고 5m 이상」)"] + tall = [ + row for row in rows if float(row.get("fill_height_m") or 0.0) >= LEVEL_MIN_FILL_HEIGHT_M + ] + return len(tall), [ + f"성토고 {LEVEL_MIN_FILL_HEIGHT_M:g}m 이상 측점 {len(tall)}곳 (품셈 11-3 [주]①)" + ] + + +def preparation_rows( + slope_totals: dict[str, float] | None = None, + slope_rows: Iterable[dict[str, Any]] = (), + topsoil_thickness_m: float | None = None, +) -> list[dict[str, Any]]: + """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" + slope = slope_totals or {} + tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( + slope.get("tree_removal_cut", 0.0) + ) + return [ + { + "group": "준비공", + "item": "벌목·지장목제거", + "unit": "㎡", + "amount": None, + "status": STATUS_COUNTED_ELSEWHERE, + # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. + "reference_amount": tree_area, + "reason": ( + "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. " + "⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·" + "위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감." + ), + "work_item_code": None, + }, + _topsoil_row(slope, topsoil_thickness_m), + { + "group": "준비공", + "item": "제근·뿌리다듬기", + "unit": "개", + "amount": None, + "status": STATUS_PENDING, + "reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)", + "work_item_code": "FP-09-21", + }, + _batter_frame_row(list(slope_rows)), + _level_frame_row(list(slope_rows)), + ] + + +def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]: + """표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」). + + ⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을 + 쓴다(사면 계열의 면고르기 면적과 같은 자리). + """ + area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0)) + if thickness_m is None or float(thickness_m) <= 0: + return { + "group": "준비공", + "item": "표토제거", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + "표토 두께가 아직 입력되지 않았습니다 — 품셈 9-15 [주]② 가 두께를 " + "「공식의 입력 변수(T)」로 두어 **품셈이 정하는 값이 아닙니다**. " + f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)" + ), + "reference_amount": area, + "work_item_code": "FP-09-15", + } + thickness = float(thickness_m) + return { + "group": "준비공", + "item": "표토제거", + "unit": "㎥", + "amount": area * thickness, + "status": STATUS_READY, + "reason": f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15)", + "work_item_code": "FP-09-15", + } + + +def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: + """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" + count, notes = batter_frame_count(slope_rows) + return { + "group": "준비공", + "item": "비탈 규준틀", + "unit": "개소", + "amount": float(count) if count else None, + "status": STATUS_READY if count else STATUS_PENDING, + "reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"), + "work_item_code": "FP-11-02", + } + + +def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: + """수평 규준틀 한 줄. 성토고를 못 보면 미확보, 보면 개소가 선다.""" + count, notes = level_frame_count(slope_rows) + return { + "group": "준비공", + "item": "수평 규준틀", + "unit": "개소", + "amount": float(count) if count is not None else None, + "status": STATUS_READY if count is not None else STATUS_PENDING, + "reason": "; ".join(notes) + + ( + " · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」라 미확보" + if count is not None + else "" + ), + "work_item_code": "FP-11-03", + } + + +def erosion_rows( + structures: Iterable[dict[str, Any]] = (), + names: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + """사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다. + + ⚠ 줄 이름은 **레지스트리 이름**을 쓴다 — `type_id` 를 그대로 적으면 화면에 + `soil_guard` 같은 개발자 키가 뜬다(B08 ㉑ 과 같은 병). `names` 가 없으면 + 문구표가 받아 주고, 그것도 없으면 키를 보이되 **지어내지는 않는다**. + """ + found = sorted( + { + str(item.get("type_id")) + for item in structures + if str(item.get("type_id")) in EROSION_CONTROL_TYPES + } + ) + if not found: + return [ + { + "group": "사방공", + "item": "사방 시설", + "unit": "", + "amount": None, + "status": STATUS_NOT_APPLICABLE, + "reason": "이 노선에 사방 시설이 배치돼 있지 않음 — 있는 것처럼 0 을 적지 않음", + "work_item_code": None, + } + ] + return [ + { + "group": "사방공", + "item": type_label(type_id, names), + "type_id": type_id, + "unit": "개소", + "amount": None, + "status": STATUS_PENDING, + "reason": "구조물 원단위가 아직 없음 — 전개식·관측값 모두 미확보", + "work_item_code": None, + } + for type_id in found + ] + + +def build_table( + slope_totals: dict[str, float] | None = None, + structures: Iterable[dict[str, Any]] = (), + slope_rows: Iterable[dict[str, Any]] = (), + topsoil_thickness_m: float | None = None, + names: dict[str, str] | None = None, +) -> dict[str, Any]: + """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" + rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows( + structures, names + ) + return { + "columns": ["구분", "공종", "단위", "수량", "상태", "사유"], + "rows": rows, + "ready_count": sum(1 for row in rows if row["status"] == STATUS_READY), + "pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING), + "row_count": len(rows), + } diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py new file mode 100644 index 00000000..47d92d1e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py @@ -0,0 +1,169 @@ +"""사면 4계열 면적 — 실무 토적표의 오른쪽 절반 (B08 일감 3 · PLAN 8-4b). + +무엇을 내나 + 실무 토적표 V~AI 열에 해당한다. 계열 넷 × 성토면/절토면 2벌 = **(거리, 면적) 7쌍** + (층따기는 성토면만이라 7쌍이다). + + 층따기[성토면] · 면고르기[성토면·절토면] · 법면보호공[성토면·절토면] · 지장목제거[성토면·절토면] + + 여기서 「거리」는 그 측점의 **사면길이**이고, 면적은 토적표와 **같은 평균단면적법**으로 + 낸다 — 계산을 두 벌로 짜지 않는다. + +법면보호공은 면고르기를 참조한다 (PLAN 8-4b) + 실무 시트에서 둘의 값이 완전히 같았는데, 그것은 **엑셀에서 면고르기 열을 복사한 것**이고 + 오솔길 산출(`1.BOM`)에는 보호공 4열이 **0** 으로 비어 있었다. 즉 산출값이 아니라 참조다. + 그래서 기본은 참조로 두되 **끊을 수 있게** 한다 — 실제 보호 대상이 면고르기 대상과 + 다를 수 있기 때문이다. + +⚠ 반영률은 법정값이 아니다 (PLAN 8-11 · 8-10 ★) + 실무 시트가 「성토면 80 % 반영」처럼 비고란에 손으로 적어 둔 값이다. **프로그램 기본은 + 100 %** 이고 설계자가 바꾼다. 실무 관측치(80/50/80)는 기본값 후보가 아니라 참고다. + +⚠ 소단 평탄부는 사면적에 넣지 않는다 + 면고르기·종자파종의 대상은 「사면」이고 소단은 평평한 턱이다. `SlopeSegment` 자체가 + 평탄부를 빼고 나오므로 여기서 다시 거를 것이 없다. 다만 **소단이 늘수록 사면적이 줄어드는 + 것이 눈에 보여야** 하므로 측점마다 소단 폭을 함께 싣는다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterable + +from B08_Quantity.B08_Quantity_Engine_SlopeLength import StationSlope + +# 계열 이름 — 실무 토적표 머리글 그대로. `fill`/`cut` 은 성토면/절토면이다. +SERIES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("bench_cut", ("fill",)), # 층따기 — 성토면만(원지반이 급한 곳을 계단으로 깎는다) + ("face_dressing", ("fill", "cut")), # 면고르기 + ("slope_protection", ("fill", "cut")), # 법면보호공(종자파종) + ("tree_removal", ("fill", "cut")), # 지장목제거 +) + +# 법면보호공이 참조하는 계열 — 기본은 면고르기다(위 설명 참조). +PROTECTION_SOURCE = "face_dressing" + + +@dataclass(slots=True) +class SlopeRatios: + """계열별 반영률(0~1). 기본 100 % — 실무 관측치는 참고일 뿐 기본값이 아니다. + + ⚠ TODO(미결 · PLAN 8-11) — 실무 관측 80/50/80 중 **지장목제거는 밑수가 안 맞는다** + (성토+절토 합의 80 % = 14,061 ≠ 시트값 10,782). 밑수를 못 찾았으므로 쫓지 않고 + 100 % 로 둔다. 근거가 나오면 이 값만 바꾼다. + """ + + bench_cut: float = 1.0 + face_dressing: float = 1.0 + slope_protection: float = 1.0 + tree_removal: float = 1.0 + + def of(self, series: str) -> float: + return float(getattr(self, series, 1.0)) + + +@dataclass(slots=True) +class SlopeAreaRow: + """측점 하나의 사면 계열 값. `lengths` 는 거리(사면길이), `areas` 는 면적.""" + + chainage_m: float + distance_m: float = 0.0 + berm_width_m: float = 0.0 + # 성토고(m) — 수평 규준틀 개소 판정(품셈 11-3 [주]① 「성토고 5m 이상」)이 쓴다. + fill_height_m: float = 0.0 + unclosed: bool = False + lengths: dict[str, float] = field(default_factory=dict) + areas: dict[str, float] = field(default_factory=dict) + + +def _key(series: str, face: str) -> str: + return f"{series}_{face}" + + +def _length_of(slope: StationSlope, series: str, face: str) -> float: + """계열·면별 「거리」 = 그 측점의 사면길이. + + 법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다. + 층따기는 성토면만 대상이다. + """ + if series == "bench_cut" and face != "fill": + return 0.0 + return slope.fill_length_m if face == "fill" else slope.cut_length_m + + +def build_rows( + slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None +) -> list[SlopeAreaRow]: + """측점별 사면길이 → 계열별 (거리, 면적). 면적은 토적표와 같은 평균단면적법.""" + rates = ratios or SlopeRatios() + ordered = sorted(slopes, key=lambda s: s.chainage_m) + rows: list[SlopeAreaRow] = [] + previous: SlopeAreaRow | None = None + + for slope in ordered: + row = SlopeAreaRow( + chainage_m=slope.chainage_m, + berm_width_m=slope.berm_width_m, + fill_height_m=slope.fill_height_m, + unclosed=slope.unclosed, + ) + for series, faces in SERIES: + for face in faces: + row.lengths[_key(series, face)] = _length_of(slope, series, face) + if previous is not None: + distance = slope.chainage_m - previous.chainage_m + row.distance_m = distance + for key, length in row.lengths.items(): + series = key.rsplit("_", 1)[0] + before = previous.lengths.get(key, 0.0) + # 평균단면적법 — 토적표와 같은 식이다(체적 대신 면적을 낸다). + row.areas[key] = (before + length) / 2.0 * distance * rates.of(series) + else: + row.areas = {key: 0.0 for key in row.lengths} + rows.append(row) + previous = row + return rows + + +def totals(rows: list[SlopeAreaRow]) -> dict[str, float]: + """계열별 면적 합계. 거리(사면길이)는 합이 뜻이 없어 싣지 않는다.""" + keys = [_key(series, face) for series, faces in SERIES for face in faces] + return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys} + + +def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]: + """사면이 원지반을 못 만나 **면적이 잘린** 측점 목록. + + 조용히 적게 내면 안 되는 값이라 화면이 이 목록을 그대로 보인다(PLAN 8-4b). + 같은 사유로 토적표의 절·성토 면적도 잘려 있다. + """ + return [row.chainage_m for row in rows if row.unclosed] + + +def build_table( + slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None +) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양.""" + rates = ratios or SlopeRatios() + rows = build_rows(slopes, rates) + return { + "method": "average_end_area", + "series": [{"name": name, "faces": list(faces)} for name, faces in SERIES], + "protection_source": PROTECTION_SOURCE, + "ratios": {name: rates.of(name) for name, _ in SERIES}, + "rows": [ + { + "chainage_m": row.chainage_m, + "distance_m": row.distance_m, + "berm_width_m": row.berm_width_m, + "fill_height_m": row.fill_height_m, + "unclosed": row.unclosed, + "lengths": row.lengths, + "areas": row.areas, + } + for row in rows + ], + "totals": totals(rows), + "unclosed_stations": unclosed_stations(rows), + "station_count": len(rows), + } diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py new file mode 100644 index 00000000..e7497743 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -0,0 +1,234 @@ +"""사면길이 유도 — 저장된 횡단 설계선에서 절토·성토 사면 구간을 가려낸다 (B08 일감 3). + +왜 유도하나 (B06 무접촉) + 설계 엔진이 `cut_slope_segments` 를 내기는 하나 **정본에 저장되지 않는다**(실측: route 150 + 측점 20.0 의 저장 `design` 키 33개에 그 키가 없음). 저장되는 것은 화면이 보낸 설계 지정이고 + 조회는 저장분을 그대로 싣는다. 그래서 그 값을 원천으로 쓰면 사면적이 조용히 0 이 된다. + + 대신 **`design_line`(설계선 폴리라인) + 저장된 경사비**로 유도한다. 필요한 입력이 전부 + 정본에 있어 B06 을 고치지 않아도 된다. + +가려내는 방법 + 노체 끝(`road_edges`)에서 바깥으로 나아가며, 구간 기울기가 **저장된 설계 경사비와 맞는 + 동안**이 사면이다. 원지반은 기울기가 안 맞아 저절로 끊긴다. 2단 사면(암/토사)도 경사비가 + 달라 그대로 갈린다. + + 실측(측점 20.0, 절토 0.4 · 토사절토 1.0 · 성토 1.2, 노체 끝 ±2.0): + -2.90 → -2.60 n=1.0 측구 바깥 벽 + -3.50 → -2.90 n=0.4 절토 사면(암) + -4.50 → -3.51 n=1.0 절토 사면(토사) + -5.00 → -4.85 n=1.63 원지반 — 여기서 멈춘다 + +⚠ 두 가지를 조심한다 + · **끝 조각은 딱 안 떨어진다** — 샘플 격자에 걸려 잘리면 `n=1.025` 처럼 나온다. 허용오차를 둔다. + · **지형이 우연히 같은 경사면** 사면이 길게 잡힐 수 있다. 노체에서 바깥으로 **연속**인 + 구간만 세고 끊기면 멈추는 것으로 막는다. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Iterable + +# 경사비 일치 허용오차(비율). 끝 조각이 격자에 잘려 생기는 오차를 덮는 크기다. +_RATIO_TOLERANCE = 0.12 +# 평탄부로 볼 기울기 — 소단·측구 바닥은 오름이 거의 없다. +_FLAT_RISE_M = 1e-6 + + +@dataclass(slots=True) +class SlopeSegment: + """사면 한 조각. `side` 는 `left`/`right`, `role` 은 `cut`/`fill`.""" + + side: str + role: str + from_offset_m: float + to_offset_m: float + rise_m: float + length_m: float + ratio: float + material: str | None = None + + +@dataclass(slots=True) +class StationSlope: + """측점 하나의 사면길이 묶음. 면적 적분이 이 값을 거리로 쓴다.""" + + chainage_m: float + cut_length_m: float = 0.0 + fill_length_m: float = 0.0 + berm_width_m: float = 0.0 + # 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다. + # ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은 + # 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다. + fill_height_m: float = 0.0 + segments: tuple[SlopeSegment, ...] = () + # 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점. + # 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은 + # 사면길이도 같이 잘려 있으므로 **조용히 적게 내지 말고 화면에 드러내야 한다.** + unclosed: bool = False + + +def _num(value: Any) -> float | None: + return float(value) if isinstance(value, (int, float)) else None + + +def _ratios(design: dict[str, Any]) -> dict[str, list[float]]: + """역할별로 받아들일 경사비 목록. 2단 사면이면 암·토사 둘 다 절토로 본다.""" + cut = [ + value + for value in ( + _num(design.get("cut_slope_ratio")), + _num(design.get("soil_cut_slope_ratio")), + ) + if value is not None and value > 0 + ] + fill = [value for value in (_num(design.get("fill_slope_ratio")),) if value and value > 0] + return {"cut": cut, "fill": fill} + + +def _match(ratio: float, candidates: list[float]) -> float | None: + """구간 경사비가 후보 중 하나와 맞으면 그 후보를 돌려준다.""" + for candidate in candidates: + if abs(ratio - candidate) <= max(_RATIO_TOLERANCE * candidate, _RATIO_TOLERANCE): + return candidate + return None + + +def _outward( + line: list[dict[str, float]], edge_offset: float, side: str +) -> list[tuple[float, float, float, float]]: + """노체 끝에서 **바깥으로** 향하는 구간 목록 `(시작오프셋, 끝오프셋, run, rise)`. + + 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다(설계선 좌표 관례). + """ + points = sorted( + ((float(p["offset_m"]), float(p["elevation_m"])) for p in line), key=lambda p: p[0] + ) + if side == "left": + outer = [p for p in points if p[0] >= edge_offset] + else: + outer = [p for p in points if p[0] <= edge_offset][::-1] + return [ + ( + outer[i - 1][0], + outer[i][0], + abs(outer[i][0] - outer[i - 1][0]), + outer[i][1] - outer[i - 1][1], + ) + for i in range(1, len(outer)) + ] + + +def slope_start_offset(design: dict[str, Any], side: str) -> float | None: + """사면이 시작하는 오프셋 — 노체 끝, 측구가 있으면 **측구 바깥 끝**. + + ⚠ 이것이 없으면 **측구 바깥 벽이 사면으로 잡힌다.** 실측(측점 20.0)에서 측구 벽 경사가 + n=1.0 으로 토사 절토비와 같아 그대로 걸렸다. 측구는 노체 배수 시설이지 사면이 아니므로 + 그 바깥 끝에서부터 세어야 한다. + """ + edges = design.get("road_edges") or {} + edge = _num((edges.get(side) or {}).get("offset_m")) + if edge is None: + return None + if not design.get("ditch_enabled"): + return edge + ditch_side = design.get("ditch_side") + if ditch_side not in (side, "both", None): + return edge + width = _num((design.get("ditch") or {}).get("top_width_m")) or 0.0 + # 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. + return edge + width if side == "left" else edge - width + + +def _side_segments( + design: dict[str, Any], side: str, ratios: dict[str, list[float]] +) -> list[SlopeSegment]: + """한쪽 사면 구간 목록. 경사비가 안 맞는 구간을 만나면 거기서 멈춘다.""" + line = design.get("design_line") or [] + edge = slope_start_offset(design, side) + if not line or edge is None: + return [] + + berm = design.get("berm") or {} + berm_width = _num(berm.get("width_m")) or 0.0 + + segments: list[SlopeSegment] = [] + started = False + for start, end, run, rise in _outward(line, float(edge), side): + if run <= 1e-9: + continue + if abs(rise) <= _FLAT_RISE_M: + # 평탄부 — 측구 바닥·소단. 사면이 시작된 뒤라면 소단으로 보고 이어 간다. + if started and berm_width > 0 and abs(run - berm_width) < 0.05: + continue + if started: + break # 사면이 끝나고 평지를 만난 것이다 + continue + ratio = run / abs(rise) + # 절토는 바깥으로 갈수록 오르고, 성토는 내려간다. + role = "cut" if rise > 0 else "fill" + matched = _match(ratio, ratios[role]) + if matched is None: + if started: + break # 원지반에 닿았다 + continue # 아직 노체·측구 구간이다 + started = True + segments.append( + SlopeSegment( + side=side, + role=role, + from_offset_m=start, + to_offset_m=end, + rise_m=rise, + length_m=math.hypot(run, rise), + ratio=matched, + material=_material(design, matched), + ) + ) + return segments + + +def _material(design: dict[str, Any], ratio: float) -> str | None: + """경사비로 재료를 가른다 — 그린 대로 적는다(B06 `cut_slope_segments` 주석과 같은 규칙).""" + if not design.get("two_stage_slope"): + return None + rock = _num(design.get("cut_slope_ratio")) + soil = _num(design.get("soil_cut_slope_ratio")) + if rock is None or soil is None or abs(rock - soil) < 1e-9: + return None + return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil" + + +def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope: + """측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다.""" + ratios = _ratios(design) + segments: list[SlopeSegment] = [] + for side in ("left", "right"): + segments.extend(_side_segments(design, side, ratios)) + berm = design.get("berm") or {} + fill_by_side = { + side: sum(abs(s.rise_m) for s in segments if s.role == "fill" and s.side == side) + for side in ("left", "right") + } + return StationSlope( + chainage_m=float(chainage_m), + cut_length_m=sum(s.length_m for s in segments if s.role == "cut"), + fill_length_m=sum(s.length_m for s in segments if s.role == "fill"), + fill_height_m=max(fill_by_side.values(), default=0.0), + berm_width_m=_num(berm.get("width_m")) or 0.0, + segments=tuple(segments), + unclosed=bool(design.get("slope_unclosed")), + ) + + +def station_slopes(records: Iterable[dict[str, Any]]) -> list[StationSlope]: + """`[{chainage_m, design}]` → 측점별 사면길이. 이정 순으로 낸다.""" + result = [ + station_slope(item["chainage_m"], item.get("design") or {}) + for item in records + if item.get("chainage_m") is not None + ] + result.sort(key=lambda s: s.chainage_m) + return result diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py new file mode 100644 index 00000000..5b9e2317 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -0,0 +1,785 @@ +"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15). + +식을 발명하지 않는다 — 실무 원본을 옮긴다 + 울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로 + 남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값 + (돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**. + 그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다. + +치수 정본은 하나다 (PLAN 8-6 ② 필수 조건) + 전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다. + 자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. + +⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙 + ㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**. + 시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다. + 실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②). + `verify_no_mix_components()` 가 이 규칙을 코드로 지킨다. + ㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7). + · **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종 + (`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다 + (울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 — + 그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다. + +⚠ 공제 규칙 (품셈 1-2-1 원문) + 「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리, + 리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.** + 치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼 + 실제로 비어 있는 자리뿐이다. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from functools import lru_cache +from dataclasses import dataclass, field +from typing import Any, Iterable + +from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( + BASIS_DERIVED, + BASIS_OBSERVED, + ObservedUnitTable, + billing_of, + expand_observed, + load_observed_table, +) +from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork +from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status +from common_util.common_util_quantity_spread import spread_by_unit + +# ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── +# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. +# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). +#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. +#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. +#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. +STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { + 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, + 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, + 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, + 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, + 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, + 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, + 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, +} +DEFAULT_BACK_LENGTH_CM = 45 + + +#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. +#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. +#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 +#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. +STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +STONE_KIND_PREFIX = "stone_kind_" +STONE_KIND_OPTION = "stone_kind" + + +@lru_cache(maxsize=1) +def load_stone_kind_table() -> dict[str, Any]: + """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" + folder = STONE_KIND_DIR + if not folder.is_dir(): + return {} + files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) + if not files: + return {} + try: + return json.loads(files[-1].read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: + """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. + + ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. + ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. + """ + table = load_stone_kind_table() + key = str(back_cm) + kind = str(options.get(STONE_KIND_OPTION) or "").strip() + if not table: + return {}, "" + if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: + return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") + if not kind: + fallback = table.get("fallback") or {} + return { + "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), + "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), + "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), + "kind": "", + }, str(fallback.get("message") or "") + if kind not in (table.get("kinds") or []): + return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" + wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) + fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) + ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) + note = "" + if wedge is None: + note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" + return { + "wedge_stone_m3_per_m2": wedge, + "fill_concrete_m3_per_m2": fill, + "backfill_ratio": ratio, + "kind": kind, + }, note + + +# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. +STONE_MASONRY = { + # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). + # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 + # (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가 + # **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고 + # 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다. + # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 + # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. + "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 + "thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께 + "thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H) + "thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H) + # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. + # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. + "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 + # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · + # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 + # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. + "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 + "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) + "excavation_extra_m": 0.2, # 터파기 폭 여유 + "backfill_thickness_m": 0.2, # 되메우기 두께 +} + +# 성분이 어디로 가는가 — 이중계상을 막는 표시. +# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) +# `material` = 자재총괄로 감(할증은 거기서 한 번만) +# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) +DESTINATION = { + "터파기": "earthwork", + "되메우기": "earthwork", + "잔토처리": "earthwork", + "돌쌓기": "unit_price", + "돌붙임": "unit_price", + "깬돌": "material", + "야면석": "material", + "고임돌": "material", + "막자갈": "material", + "콘크리트": "unit_price", + "채움콘크리트": "unit_price", + "모르터": "unit_price", + "거푸집": "unit_price", + "물구멍관": "material", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) +} + +# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. +# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 +# 오탐된다 — 개발 중 실제로 걸렸던 자리다. +MIX_COMPONENTS = frozenset( + {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} +) + + +@dataclass(slots=True) +class Component: + """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" + + name: str + unit: str + amount: float + destination: str + basis: str = "" + # ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). + # 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다. + basis_kind: str = BASIS_DERIVED + source: str = "" + + +@dataclass(slots=True) +class StructureQuantity: + """구조물 하나의 원단위 전개 결과.""" + + structure_id: str | None + type_id: str + name: str + length_m: float = 0.0 + height_m: float = 0.0 + # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). + start_m: float | None = None + end_m: float | None = None + # 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다. + # 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나). + options: dict[str, Any] = field(default_factory=dict) + components: list[Component] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + #: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인 + #: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증). + #: 비어 있으면 종전대로 「m · 연장」으로 선다. + billing_unit: str = "" + billing_quantity: float = 0.0 + + +#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). +#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** +#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. +#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). +BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") + + +def _back_length(options: dict[str, Any]) -> int: + """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. + + ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 + 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — + 접으면 다른 규격 계수가 조용히 돈다. + """ + for key in BACK_LENGTH_KEYS: + raw = options.get(key) + if raw is None: + continue + try: + value = int(float(raw)) + except (TypeError, ValueError): + continue + # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 + # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). + # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. + return value + return DEFAULT_BACK_LENGTH_CM + + +def _num(value: Any, fallback: float = 0.0) -> float: + return float(value) if isinstance(value, (int, float)) else fallback + + +#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). +#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 +#: 「뒷길이 45㎝」 계수로 돌던 자리다. +BOULDER_DIAMETERS = ("40~60", "60~80", "80~100") + +#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. +#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다. +BOULDER_MASONRY = { + # ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것. + # 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다. + "sheet_check_factor_at_0_3": 1.04, + "excavation_extra_m": 0.2, # 터파기 폭 여유 + "backfill_thickness_m": 0.2, # 되메우기 두께 +} + + +def _boulder_diameter(options: dict[str, Any]) -> str | None: + """저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**.""" + raw = options.get("stone_cm") + text = str(raw).strip() if raw is not None else "" + return text if text in BOULDER_DIAMETERS else None + + +def boulder_masonry( + height_m: float, length_m: float, options: dict[str, Any] +) -> tuple[list[Component], list[str]]: + """큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다. + + ⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. + 돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을 + 내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다. + + ⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) — + 따로 세우면 이중계상이다. + + ⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다. + 두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라 + 한 값으로 못 정한다. 미확보로 둔다. + """ + notes: list[str] = [] + if height_m <= 0 or length_m <= 0: + return [], ["높이·연장이 없어 전개하지 않음"] + + diameter = _boulder_diameter(options) + if diameter is None: + return [], [ + "큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " + f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" + ] + + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 레지스트리에 칸 없음 — 기본 0.3 + constants = BOULDER_MASONRY + face_area = height_m * length_m + # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). + masonry_area = face_area * math.hypot(1.0, slope_ratio) + + components = [ + Component( + "큰돌쌓기", + "㎡", + masonry_area, + DESTINATION["돌쌓기"], + f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)", + ) + ] + + # 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다. + # ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로 + # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. + upper_cm = float(str(diameter).split("~")[-1]) + thickness = upper_cm / 100.0 + excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m + backfill = height_m * constants["backfill_thickness_m"] * length_m + components.extend( + [ + Component( + "터파기", + "㎥", + excavation, + DESTINATION["터파기"], + f"높이 × (벽두께 {thickness:g}m + 0.2) × 연장 · ⚠ 큰돌쌓기 터파기 폭 규정이" + " 품셈에 없어 돌쌓기 방식을 준용", + ), + Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), + Component( + "잔토처리", + "㎥", + excavation - backfill, + DESTINATION["잔토처리"], + "터파기 − 되메우기", + ), + ] + ) + + notes.append( + "재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 " + "돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음" + ) + notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음") + notes.append( + "뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 " + "직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보" + ) + notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름") + return components, notes + + +def stone_masonry( + height_m: float, length_m: float, options: dict[str, Any], wet: bool +) -> tuple[list[Component], list[str]]: + """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. + + 실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3): + 정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245 + 터파기 1.55 · 되메우기 0.30 · 잔토 1.25 + """ + notes: list[str] = [] + if height_m <= 0 or length_m <= 0: + return [], ["높이·연장이 없어 전개하지 않음"] + + back_cm = _back_length(options) + if back_cm not in STONE_BACK_LENGTH_TABLE: + # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. + return [], [ + f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" + ] + table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) + # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). + # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 + # 멈추면 이미 저장된 프로젝트가 통째로 빈다. + picked, kind_note = stone_coefficients(options, back_cm) + if kind_note: + notes.append(kind_note) + # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 + # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). + if picked.get("kind"): + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + table[key] = picked.get(key) + else: + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + if picked.get(key) is not None: + table[key] = picked[key] + #: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다. + #: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서 + #: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다. + #: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음). + backfill_share = picked.get("backfill_ratio") + body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0 + kind_label = str(picked.get("kind") or "") + # ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다. + # 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다. + # (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.) + # **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** / + # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 + # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3) + constants = STONE_MASONRY + + face_area = height_m * length_m # 정면적 + # 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.** + # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). + masonry_area = face_area * math.hypot(1.0, slope_ratio) + thickness = ( + (constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m) + + (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m) + ) / 2.0 + volume = face_area * thickness # 입적 + + components = [ + Component( + "돌쌓기", + "㎡", + masonry_area, + DESTINATION["돌쌓기"], + f"정면적 × √(1+{slope_ratio}²) — 비탈면적", + ), + ] + # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). + # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 + # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. + if table["wedge_stone_m3_per_m2"] is None: + notes.append( + f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" + + (f" · {kind_label}" if kind_label else "") + + " 칸이 「-」입니다" + ) + else: + components.append( + Component( + "고임돌", + "㎥", + masonry_area * _num(table["wedge_stone_m3_per_m2"]), + DESTINATION["고임돌"], + f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + + (f" · {kind_label}" if kind_label else ""), + ) + ) + + stone_ton = table["stone_ton_per_m2"] + if stone_ton is None: + # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). + notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함") + else: + components.append( + Component( + "야면석", + "ton", + masonry_area * stone_ton, + DESTINATION["야면석"], + f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", + ) + ) + + # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). + # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3). + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌 + rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge) + if rubble > 0: + components.append( + Component( + "막자갈", + "㎥", + rubble, + DESTINATION["막자갈"], + f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌)" + + (f" · {kind_label}" if kind_label else ""), + ) + ) + + if wet: + components.append( + Component( + "채움콘크리트", + "㎥", + masonry_area * _num(table["fill_concrete_m3_per_m2"]), + DESTINATION["채움콘크리트"], + f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", + ) + ) + components.append( + Component( + "모르터", + "㎥", + masonry_area * constants["mortar_m3_per_m2"], + DESTINATION["모르터"], + f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)", + ) + ) + # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). + + # 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m. + # ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고 + # 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다. + # ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에 + # 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면 + # 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다 + # (`structure_unit_observed` 의 `double_count_rules`). + # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). + # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 + # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. + components.append( + Component( + "물구멍관", + "m", + masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], + DESTINATION["물구멍관"], + # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — + # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). + "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·" + " 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)", + ) + ) + + # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). + excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m + backfill = height_m * constants["backfill_thickness_m"] * length_m + components.extend( + [ + Component( + "터파기", "㎥", excavation, DESTINATION["터파기"], "높이 × (평균두께+0.2) × 연장" + ), + Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"), + Component( + "잔토처리", + "㎥", + excavation - backfill, + DESTINATION["잔토처리"], + "터파기 − 되메우기", + ), + ] + ) + return components, notes + + +# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). +# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. +# 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면 +# 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다. +OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = { + "retaining_wall": ("form", "height_m"), + "ford_pavement": ("thickness_cm",), + # 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다. + "pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"), +} + +EXPANDERS = { + "masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True), + "masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False), + "boulder_masonry": lambda h, l, o: boulder_masonry(h, l, o), + # ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견). + # 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.** + # 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝). + # 앞서 `stone_masonry(dry)` 로 전개하고 있었는데, 그러면 직경 60~80㎝ 짜리가 + # **「뒷길이 45㎝」 계수로 돌아 조용히 틀린 값**이 나온다(고임돌 0.15·야면석 0.88 …). + # ⚠ 값이 나오기는 하므로 어떤 시험도 안 잡던 자리다 — 「값이 있기는 하니 안 보이는」 그것. + # 전개식·관측 원단위가 설 때까지 **미확보로 드러낸다.** +} + +#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다. +EXPANDER_WITHHELD: dict[str, str] = {} + + +#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다 +#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다. +ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = { + # (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리) + "pipe": (("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),), +} + + +def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]: + """구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지).""" + rows: list[dict[str, Any]] = [] + options = structure.get("options") or {} + for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()): + if not options.get(gate_key): + continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다 + rows.append( + { + **structure, + "structure_id": f"{structure.get('structure_id')}-{type_id}", + "type_id": type_id, + "attachment_of": structure.get("structure_id"), + "attachment_parent_type": structure.get("type_id"), + "attachment_label": label, + } + ) + return rows + + +def _observed_components( + type_id: str, + structure: dict[str, Any], + observed: ObservedUnitTable | None, +) -> tuple[list[dict[str, Any]], list[str]]: + """관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다.""" + keys = OBSERVED_SPEC_KEYS.get(type_id) + if keys is None: + return [], [] + options = structure.get("options") or {} + spec = {key: options[key] for key in keys if options.get(key) is not None} + if not spec: + from B08_Quantity.B08_Quantity_Wording import option_missing + + return [], [option_missing(keys[0], type_id)] + return expand_observed(type_id, spec, structure, observed) + + +def _observed_billing( + type_id: str, + structure: dict[str, Any], + observed: ObservedUnitTable | None, +) -> tuple[str, float] | None: + """관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다.""" + keys = OBSERVED_SPEC_KEYS.get(type_id) + if keys is None: + return None + options = structure.get("options") or {} + spec = {key: options[key] for key in keys if options.get(key) is not None} + if not spec: + return None + return billing_of(type_id, spec, structure, observed) + + +def expand( + structure: dict[str, Any], + names: dict[str, str] | None = None, + observed: ObservedUnitTable | None = None, +) -> StructureQuantity: + """구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지).""" + type_id = str(structure.get("type_id") or "") + options = structure.get("options") or {} + start = _num(structure.get("start_m")) + end = _num(structure.get("end_m")) + length = _num(options.get("length_m")) or abs(end - start) + height = _num(options.get("height_m")) + label = (names or {}).get(type_id, type_id) + if structure.get("attachment_label"): + # 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다. + parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "") + label = f"{parent or label} · {structure['attachment_label']}".strip(" ·") + result = StructureQuantity( + structure_id=structure.get("structure_id"), + type_id=type_id, + name=label, + length_m=length, + height_m=height, + start_m=start if structure.get("start_m") is not None else None, + end_m=end if structure.get("end_m") is not None else None, + options=dict(options), + ) + withheld = EXPANDER_WITHHELD.get(type_id) + if withheld: + result.notes.append(f"전개식 미확보 — {withheld}") + return result + + expander = EXPANDERS.get(type_id) + if expander is None: + # 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류). + components, notes = _observed_components(type_id, structure, observed) + if components or notes: + result.components = [Component(**item) for item in components] + result.notes.extend(notes) + billing = _observed_billing(type_id, structure, observed) + if billing is not None: + result.billing_unit, result.billing_quantity = billing + return result + from B08_Quantity.B08_Quantity_Wording import type_label + + result.notes.append( + f"{type_label(type_id, names)}의 수량 산출식이 아직 없습니다 — 물량이 서지 않습니다" + ) + return result + result.components, notes = expander(height, length, options) + result.notes.extend(notes) + return result + + +def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]: + """⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어). + + 시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다. + 실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다. + """ + found: list[str] = [] + for item in quantities: + for component in item.components: + if component.name.strip() in MIX_COMPONENTS: + found.append(f"{item.name}({item.type_id}) 의 '{component.name}'") + return found + + +def build_table( + structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None +) -> dict[str, Any]: + """화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.""" + observed = load_observed_table() + # 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다. + expanded_inputs: list[dict[str, Any]] = [] + for item in structures: + expanded_inputs.append(item) + expanded_inputs.extend(attachments_of(item)) + quantities = [expand(item, names, observed) for item in expanded_inputs] + violations = verify_no_mix_components(quantities) + + totals: dict[str, dict[str, Any]] = {} + for item in quantities: + for component in item.components: + key = f"{component.name}|{component.unit}" + entry = totals.setdefault( + key, + { + "name": component.name, + "unit": component.unit, + "amount": 0.0, + "destination": component.destination, + }, + ) + entry["amount"] += component.amount + + payload_structures = [ + { + "structure_id": item.structure_id, + "type_id": item.type_id, + "name": item.name, + "length_m": item.length_m, + "height_m": item.height_m, + "start_m": item.start_m, + "end_m": item.end_m, + # 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다. + "billing_unit": item.billing_unit, + "billing_quantity": item.billing_quantity, + # 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다. + "options": item.options, + "notes": item.notes, + "components": [ + { + "name": component.name, + "unit": component.unit, + "amount": component.amount, + "destination": component.destination, + "basis": component.basis, + "basis_kind": component.basis_kind, + "source": component.source, + } + for component in item.components + ], + } + for item in quantities + ] + # 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫). + formwork_notes, formwork_missing = annotate_formwork(payload_structures) + + return { + "structures": payload_structures, + "formwork_notes": formwork_notes, + "formwork_reuse_missing": formwork_missing, + # 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다. + "shoring": shoring_status(), + # ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. + # 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지. + "pending_choices": (observed.pending_choices or {}).get("items") or [], + "totals": sorted(totals.values(), key=lambda entry: entry["name"]), + # 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠). + "surcharge_applied": False, + "mix_components_found": violations, + "structure_count": len(quantities), + "amount_spread": spread_by_unit(totals.values(), value_key="amount"), + } diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py new file mode 100644 index 00000000..fc4aeaff --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -0,0 +1,289 @@ +"""B08 토적표 조회 라우터 (일감 2 · PLAN 8-4b). + +값은 어디서 오나 + 측점별 단면적은 **B06 이 이미 낸 정본**이다(`cross_sections.data.design` 의 + `cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`). + B08 은 그것을 다시 재지 않고 **평균단면적법으로 체적화만** 한다. + +계산 자리 (CLAUDE.md 5장) + 초기값은 서버가 한 번 계산해 영구저장한다. 여기서는 저장된 단면적을 읽어 표를 만든다 — + 새 수량을 낳지 않으므로 캐시·조작 경로가 따로 필요 없다. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B06_Section.B06_Section_Repository import ( + get_cross_section_designs, + get_longitudinal_section, + get_workflow_route_context, +) +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput +from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table +from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table +from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table +from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan +from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping +from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table +from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows +from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table +from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes +from common_util.common_util_project_settings import ( + CONCRETE_PLACING_METHODS, + ROCK_METHODS, + application_ratio, + concrete_placing_method, + quantity_settings, + rock_classes, + save_section, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) + + +def _stations(designs: list[dict[str, Any]]) -> list[StationArea]: + return [ + StationArea.from_design(item["chainage_m"], item.get("design") or {}) for item in designs + ] + + +@router.get("/{project_id}/quantity/{route_id}/earthwork-table") +async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: + """토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다. + + 실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을 + 두 벌로 들게 된다. + """ + try: + designs = await run_with_connection(get_cross_section_designs, route_id) + except Exception: + logger.exception("B08 토적표 조회 실패: project_id=%s route_id=%s", project_id, route_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "토적표를 만들지 못했습니다."}, + ) + table = build_table(_stations(designs)) + # 사면 계열은 저장된 설계선에서 유도한다. + slope = build_slope_table(station_slopes(designs)) + table["slope"] = slope + + settings, project_root = await _project_settings(project_id) + plan = await _stored_haul_plan(project_id, route_id) + haul = build_haul_table(plan) + # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** + # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. + table["pipe_lengths"] = [ + { + "chainage_m": row.get("chainage_m"), + "pipe_length_m": (row.get("design") or {}).get("pipe_length_m"), + } + for row in designs + if isinstance(row, dict) and (row.get("design") or {}).get("pipe_length_m") is not None + ] + # 횡단이 선 측점 목록 — 관 줄이 「길이가 없음」과 「횡단 자체가 없음」을 가르는 데 쓴다. + table["section_chainages"] = [row.get("chainage_m") for row in designs if isinstance(row, dict)] + table["haul"] = haul + # 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다. + table["haul_available"] = bool(plan) + # ⚠ 검산을 **실제로 부른다** — 무대·도자·덤프 합이 운반계획 총량과 맞는가(8-7 ㉡). + # 2026-09-08 ㉘ 자기 감사: 만들어 두고 시험에서만 부르고 있었다. 값을 막지는 않고 + # 차이만 실어 화면이 띄우게 한다 — 막으면 계획이 없는 정상 상태에서도 멈춘다. + if plan: + check = check_against_plan(haul, plan) + table["haul_check"] = { + "hauled_total_m3": check.hauled_total_m3, + "plan_total_m3": check.plan_total_m3, + "difference_m3": check.difference_m3, + "by_equipment": check.details, + } + + table["summary"] = build_summary_table( + SummaryInput( + earthwork_totals=table.get("totals") or {}, + slope_totals=slope.get("totals") or {}, + haul_rows=summary_input_rows(haul), + rock_classes=rock_classes(settings), + rock_ratios_pct=settings.get("rock_ratios_pct") or {}, + application_ratios={ + key: application_ratio(settings, key) + for key in (settings.get("application_ratios_pct") or {}) + }, + ) + ) + # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). + structures = await _route_structures(project_id) + table["preparation"] = build_preparation_table( + slope.get("totals") or {}, + structures, + slope.get("rows") or [], + settings.get("topsoil_thickness_m"), + {type_id: definition.name for type_id, definition in structure_type_map().items()}, + ) + method, method_is_default = concrete_placing_method(settings) + # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. + table["concrete_placing"] = { + "method": method, + "is_default": method_is_default, + # ⚠ **표시 전용 참고값** — 「무엇을 정해야 하는지」만으로는 부족하고 + # 「정하면 얼마나 달라지는지」가 보여야 사용자가 판단한다(2026-09-07 조율 창). + # B08 의 어떤 계산에도 안 들어간다. + "price_hint": (load_mapping().concrete_placing or {}).get("price_hint_krw_per_m3"), + } + table["settings"] = settings + table["project_root_known"] = project_root is not None + table["route_id"] = route_id + return JSONResponse(content=table) + + +async def _route_structures(project_id: UUID) -> list[dict[str, Any]]: + """배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록.""" + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + root = resolve_stored_project_path(stored_path) + from B05_Profile.B05_Profile_Structures_Repository import load_structures + + _revision, items = load_structures(root) + return [item.model_dump() for item in items] + except Exception: + logger.warning("B08 준비공 — 구조물 목록을 못 읽음: project_id=%s", project_id) + return [] + + +async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]: + """프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다.""" + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + root = resolve_stored_project_path(stored_path) + except Exception: + logger.warning("B08 프로젝트 경로를 못 찾음: project_id=%s", project_id) + from common_util.common_util_project_settings import default_settings + + return default_settings()["quantity"], None + return quantity_settings(root), root + + +async def _stored_haul_plan(project_id: UUID, route_id: int) -> dict[str, Any] | None: + """정본에 남은 **배분**(`mass_haul.haul_plan`). [확정]을 아직 안 돌렸으면 없다. + + ⚠ 정본에 저장되는 것은 유토곡선 한 벌(`mass_haul`)이고 **배분은 그 안의 `haul_plan`** 이다. + 바깥 껍데기를 그대로 넘기면 `blocks` 를 못 찾아 **운반 표가 영영 0줄**이 된다 — + [확정] 전에는 어차피 빈 표라 화면에서 티가 안 나던 자리다(2026-09-07 실증에서 잡음). + """ + try: + row = await run_with_connection(get_longitudinal_section, project_id, route_id) + except Exception: + logger.exception("B08 운반계획 조회 실패: route_id=%s", route_id) + return None + data = (row or {}).get("data") or {} + mass_haul = data.get("mass_haul") if isinstance(data, dict) else None + if not isinstance(mass_haul, dict): + return None + plan = mass_haul.get("haul_plan") + return plan if isinstance(plan, dict) and plan else None + + +class QuantitySettingsBody(BaseModel): + """[저장]이 보내는 산출 조건. 보내지 않은 칸은 저장분을 그대로 둔다.""" + + rock_class_set: str | None = None + rock_classes: list[str] | None = None + rock_ratios_pct: dict[str, float] | None = None + # 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다. + rock_methods: dict[str, str] | None = None + application_ratios_pct: dict[str, float] | None = None + # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. + # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). + material_supply: dict[str, Any] | None = None + # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. + concrete_placing_method: str | None = None + # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). + topsoil_thickness_m: float | None = None + + +#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**. +#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다. +NULLABLE_SETTING_KEYS = ("topsoil_thickness_m",) + + +@router.put("/{project_id}/quantity/settings") +async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse: + """산출 조건을 정본에 남긴다 — [저장]이 부르는 자리. + + ⚠ 자동저장이 아니다(CLAUDE.md 5장). 조작은 캐시에 쌓이고 여기서만 작업본으로 넘어간다. + ⚠ `quantity` 구획만 쓴다 — `estimation` 은 B09 것이라 손대지 않는다(모듈이 막고 있다). + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 설정 저장 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + values = {key: value for key, value in body.model_dump().items() if value is not None} + # ⚠ `None` 을 통째로 버리면 **「안 정함」으로 되돌릴 길이 없다** — 한 번 넣은 값이 + # 영영 남는다(2026-09-08 ㉘ 자기 감사). 시공법·타설 방식은 빈 문자열로 되돌리지만 + # 숫자 칸은 되돌리는 값이 `None` 뿐이라, **화면이 보낸 것**만 골라 살린다. + for key in NULLABLE_SETTING_KEYS: + if key in body.model_fields_set: + values[key] = getattr(body, key) + if "concrete_placing_method" in values: + method = values["concrete_placing_method"] + # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). + values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None + if "rock_methods" in values: + # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 + # 여기서 버리면 그 갈래는 미지정으로 돌아간다. + values["rock_methods"] = { + name: method + for name, method in values["rock_methods"].items() + if method in ROCK_METHODS + } + try: + # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 + # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). + saved = await asyncio.to_thread( + _save_quantity, + root, + values, + ("rock_methods", "material_supply", "concrete_placing_method") + NULLABLE_SETTING_KEYS, + ) + except Exception: + logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}}) + + +def _save_quantity( + root: str, values: dict[str, Any], replace_keys: tuple[str, ...] +) -> dict[str, Any]: + return save_section(root, "quantity", values, replace_keys=replace_keys) + + +@router.get("/{project_id}/quantity/earthwork-table") +async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse: + """경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다.""" + context = await run_with_connection(get_workflow_route_context, project_id) + if not context or not context.get("route_id"): + return JSONResponse( + status_code=404, + content={"status": "error", "message": "이 프로젝트에 확정된 노선이 없습니다."}, + ) + return await get_earthwork_table(project_id, int(context["route_id"])) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py new file mode 100644 index 00000000..b95b5d64 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -0,0 +1,248 @@ +"""B08 구조물 원단위·자재총괄 조회 라우터 (일감 6·7 · PLAN 8-2·8-6·8-7). + +값은 어디서 오나 + 치수 정본은 **`structures.json` 하나**다(B05 가 주인). B08 은 자기 치수표를 들지 않고 + 그 제원을 읽어 전개할 뿐이다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다. + +⚠ `design_owner` 가 붙은 타입은 건너뛴다 + 측구가 그렇다 — 횡단 설계가 이미 터파기 단면적까지 셈하므로 구조물로 또 세면 **같은 것을 + 두 번 계상**한다(레지스트리 주석, 2026-09-07 조사). 건너뛴 것은 숨기지 않고 응답에 적는다. + +⚠ 할증은 자재총괄 한 곳뿐이다 (㉠) + 원단위표는 할증 **전** 값(`surcharge_applied: False`)으로 오고, 자재총괄이 한 번 붙인다. + 응답에 두 깃발이 다 실리므로 화면·B09 가 어느 쪽 값인지 헷갈릴 일이 없다. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize +from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table +from common_util.common_util_project_settings import ( + concrete_placing_method, + quantity_settings, + rock_classes, + rock_method, +) +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_structure_lengths import structure_lengths +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) + + +def _collect_structures( + project_root: str, +) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]: + """전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.""" + _revision, items = load_structures(project_root) + types = structure_type_map() + targets: list[dict[str, Any]] = [] + names: dict[str, str] = {} + skipped: list[str] = [] + for item in items: + payload = item.model_dump() + type_id = str(payload.get("type_id") or "") + definition = types.get(type_id) + if definition is None: + skipped.append(f"{type_id}: 레지스트리에 없는 타입") + continue + names[type_id] = definition.name + if definition.design_owner: + skipped.append( + f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지" + ) + continue + if definition.reference_only: + skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만") + continue + if definition.group == "B": + # ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가 + # 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다. + # 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다. + continue + targets.append(payload) + return targets, names, sorted(set(skipped)) + + +@router.get("/{project_id}/quantity/material-summary") +async def get_material_summary(project_id: UUID) -> JSONResponse: + """구조물 원단위와 자재총괄을 **한 응답**으로 낸다. + + 자재총괄은 원단위의 `material` 성분만 모은 것이라 따로 부르면 같은 전개를 두 번 돈다. + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + project_root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 자재총괄 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + try: + structures, names, skipped = _collect_structures(project_root) + except Exception: + logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."}, + ) + + unit_table = build_unit_table(structures, names) + settings = quantity_settings(project_root) + material_table = build_material_table( + unit_table, + supply_map=settings.get("material_supply") or {}, + ) + # 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다. + handoff = build_handoff(unit_quantity_table=unit_table) + composite = [ + { + "name": row["name"], + "parts": row["composite_parts"], + "not_ready": row.get("composite_not_ready"), + } + for row in handoff["work_items"] + # 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다. + if row.get("composite_parts") or row.get("composite_not_ready") + ] + return JSONResponse( + content={ + "unit_quantity": unit_table, + "material": material_table, + "composite": composite, + "skipped_structures": skipped, + "structure_count": len(structures), + } + ) + + +@router.get("/{project_id}/quantity/handoff") +async def get_handoff(project_id: UUID) -> JSONResponse: + """B09 로 넘길 두 벌 — 작업 공종 축과 자재 축 (일감 9). + + ⚠ **한 벌로 합치지 않는다.** 내역 줄은 작업 공종이고 자재는 자재다. + 자재에 공종코드를 붙이면 자재가 내역 줄로 오해된다(8-2 이중계상 함정). + + ⚠ 토공·운반은 토적표 라우터가 이미 만드는 표를 그대로 받는다 — 여기서 다시 계산하지 + 않는다. 같은 값을 두 벌로 짜지 않는다는 규칙(CLAUDE.md 5장)이 여기에도 걸린다. + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + project_root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 인계 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + structures, names, skipped = _collect_structures(project_root) + unit_table = build_unit_table(structures, names) + settings = quantity_settings(project_root) + material_table = build_material_table( + unit_table, supply_map=settings.get("material_supply") or {} + ) + + # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. + earthwork = await _earthwork_tables(project_id) + + handoff = build_handoff( + summary_table=earthwork.get("summary"), + haul_table=earthwork.get("haul"), + unit_quantity_table=unit_table, + material_table=material_table, + # 준비공·사방공 — 값이 서는 줄도, 못 내는 줄도 함께 넘긴다(빼면 빠진 줄이 안 보임). + preparation_table=earthwork.get("preparation"), + # B군 종단배수 — 겹침을 합친 연장. 그 규칙이 이미 그 함수에 있어 두 벌로 안 짠다. + length_table=[row for row in structure_lengths(project_root) if row.get("group") == "B"], + # 배수관 — 관 정본은 `pipe_points.json`, 연장은 측점 `design.pipe_length_m` 다. + pipe_table=_pipe_table( + project_root, + earthwork.get("pipe_lengths") or [], + earthwork.get("section_chainages") or [], + ), + ground_class_set=settings.get("rock_class_set"), + ground_classes=rock_classes(settings), + ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)}, + # 타설 방식 — 안 정했으면 기본값으로 서되 그 사실을 `placing_notes` 가 알린다. + concrete_placing_method=concrete_placing_method(settings)[0], + ) + handoff["summary"] = summarize(handoff) + handoff["skipped_structures"] = skipped + handoff["earthwork_available"] = bool(earthwork) + return JSONResponse(content=handoff) + + +def _pipe_table( + project_root: str, + pipe_lengths: list[dict[str, Any]], + section_chainages: list[Any] | None = None, +) -> dict[str, Any]: + """배수관 표 — 정본 셋을 읽어 잇는다. 못 읽으면 **빈 표**(줄이 안 서는 것이 정직하다). + + ⚠ 관 정본은 `structures.json` 이 아니라 `pipe_points.json` 이다 + (레지스트리 `pipe` 타입이 `managed_by: pipe_points`). + """ + from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows as build_pipe_rows + from common_util.common_util_drainage_pipes import pipe_points_path_in + + path = pipe_points_path_in(Path(project_root)) + if not path.is_file(): + return {"rows": [], "notes": [], "pipe_count": 0, "ready_count": 0, "length_total_m": 0.0} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.exception("B08 관 지점 읽기 실패: %s", path) + return { + "rows": [], + "notes": ["관 지점 파일을 읽지 못했습니다"], + "pipe_count": 0, + "ready_count": 0, + "length_total_m": 0.0, + } + points = payload.get("points") or payload.get("items") or [] + # 토적표 라우터가 실어 준 모양을 엔진이 읽는 모양으로 옮긴다. + designs = [ + {"chainage_m": row.get("chainage_m"), "design": {"pipe_length_m": row.get("pipe_length_m")}} + for row in pipe_lengths + ] + return build_pipe_rows( + points, + designs, + (load_mapping().pipe or {}), + [float(x) for x in (section_chainages or []) if x is not None], + ) + + +async def _earthwork_tables(project_id: UUID) -> dict[str, Any]: + """토적표 라우터가 만든 집계·운반 표를 얻는다. 노선이 없으면 빈 값.""" + from B08_Quantity.B08_Quantity_Router_Earthwork import ( + get_earthwork_table_for_current_route, + ) + + try: + response = await get_earthwork_table_for_current_route(project_id) + except Exception: + logger.exception("B08 인계 — 토적표 조회 실패: project_id=%s", project_id) + return {} + if response.status_code != 200: + return {} + import json as _json + + return _json.loads(bytes(response.body).decode("utf-8")) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts new file mode 100644 index 00000000..be3184cc --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -0,0 +1,411 @@ +/* ============================================================================= + * B08_Quantity_UI_EarthworkGrid.ts + * 토적표 그리드 — 실무 토적표(3단 머리글)를 그대로 그린다 (PLAN 8-4b). + * + * 왜 실무 서식 그대로인가 + * 이 화면의 첫 사용자는 「프로그램이 맞나」를 확인하려는 설계자다. 보기 좋게 재배치하면 + * 실무 산출서와 눈으로 대조를 못 한다. 열 순서·머리글 문구를 실무 시트에 맞춘다. + * + * ⚠ 소수 자리는 표기 규칙일 뿐이다 (PLAN 8-16) + * 서버는 전정밀 값을 준다. 자르는 것은 여기(화면)뿐이다. 실무 시트 관측 그대로 + * 단면적·체적 2자리 · 보정량계·유용토·차인·누가 1자리 · 거리 정수로 보인다. + * 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것. + * ========================================================================== */ + +/** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */ +export interface EarthworkRow { + chainage_m: number; + distance_m: number; + cut_soil_area_m2: number; + cut_soil_volume_m3: number; + cut_soil_adjusted_m3: number; + cut_rock_area_m2: number; + cut_rock_volume_m3: number; + cut_rock_adjusted_m3: number; + ditch_soil_area_m2: number; + ditch_soil_volume_m3: number; + ditch_soil_adjusted_m3: number; + ditch_rock_area_m2: number; + ditch_rock_volume_m3: number; + ditch_rock_adjusted_m3: number; + adjusted_total_m3: number; + fill_area_m2: number; + fill_volume_m3: number; + diverted_m3: number; + balance_m3: number; + cumulative_m3: number; +} + +/** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */ +export interface SlopeRow { + chainage_m: number; + distance_m: number; + berm_width_m: number; + unclosed: boolean; + lengths: Record; + areas: Record; +} + +export interface SlopeTable { + rows: SlopeRow[]; + totals: Record; + ratios: Record; + unclosed_stations: number[]; +} + +/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */ +export interface QuantitySettings { + rock_class_set?: string; + rock_classes?: string[]; + rock_ratios_pct?: Record; + application_ratios_pct?: Record; + /** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */ + rock_methods?: Record; + /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ + material_supply?: Record; + /** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */ + concrete_placing_method?: string | null; + /** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */ + topsoil_thickness_m?: number | null; +} + +export interface EarthworkTable { + method: string; + station_count: number; + route_id?: number; + rows: EarthworkRow[]; + totals: Record; + conversion_factors?: Record; + slope?: SlopeTable; + /** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */ + summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable; + haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable; + /** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */ + haul_available?: boolean; + settings?: QuantitySettings; +} + +/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ +interface Column { + key: keyof EarthworkRow; + digits: number; + /** 합계행에 낼지 — 단면적은 합이 뜻이 없어 비운다(실무 시트도 비어 있다). */ + sum?: boolean; +} + +/** 실무 토적표 3단 머리글. 대분류 → 중분류 → 소분류 순서가 곧 열 순서다. */ +const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ + { label: "", sub: [{ label: "측 점", cols: [{ key: "chainage_m", digits: 0 }] }] }, + { label: "", sub: [{ label: "거 리", cols: [{ key: "distance_m", digits: 0, sum: true }] }] }, + { + label: "절 토", + sub: [ + { + label: "토 사", + cols: [ + { key: "cut_soil_area_m2", digits: 2 }, + { key: "cut_soil_volume_m3", digits: 2, sum: true }, + { key: "cut_soil_adjusted_m3", digits: 2, sum: true }, + ], + }, + { + label: "암 석", + cols: [ + { key: "cut_rock_area_m2", digits: 2 }, + { key: "cut_rock_volume_m3", digits: 2, sum: true }, + { key: "cut_rock_adjusted_m3", digits: 2, sum: true }, + ], + }, + ], + }, + { + label: "측 구 터 파 기", + sub: [ + { + label: "토 사", + cols: [ + { key: "ditch_soil_area_m2", digits: 2 }, + { key: "ditch_soil_volume_m3", digits: 2, sum: true }, + { key: "ditch_soil_adjusted_m3", digits: 2, sum: true }, + ], + }, + { + label: "암 석", + cols: [ + { key: "ditch_rock_area_m2", digits: 2 }, + { key: "ditch_rock_volume_m3", digits: 2, sum: true }, + { key: "ditch_rock_adjusted_m3", digits: 2, sum: true }, + ], + }, + ], + }, + { + label: "", + sub: [{ label: "보정량계", cols: [{ key: "adjusted_total_m3", digits: 1, sum: true }] }], + }, + { + label: "성 토", + sub: [ + { + label: "", + cols: [ + { key: "fill_area_m2", digits: 2 }, + { key: "fill_volume_m3", digits: 2, sum: true }, + ], + }, + ], + }, + { label: "", sub: [{ label: "유 용 토", cols: [{ key: "diverted_m3", digits: 1, sum: true }] }] }, + { label: "", sub: [{ label: "차인토량", cols: [{ key: "balance_m3", digits: 1, sum: true }] }] }, + { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] }, +]; + +/** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다. + * 키는 엔진과 같은 이름을 쓴다 — 이름이 어긋나면 값이 조용히 빈다. */ +const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] = [ + { label: "층 따 기", faces: [{ key: "bench_cut_fill", label: "성 토 면" }] }, + { + label: "면고르기", + faces: [ + { key: "face_dressing_fill", label: "성 토 면" }, + { key: "face_dressing_cut", label: "절 토 면" }, + ], + }, + { + label: "법 면 보 호 공", + faces: [ + { key: "slope_protection_fill", label: "종자파종(성토)" }, + { key: "slope_protection_cut", label: "종자파종(절토)" }, + ], + }, + { + label: "지 장 목 제 거", + faces: [ + { key: "tree_removal_fill", label: "성 토 면" }, + { key: "tree_removal_cut", label: "절 토 면" }, + ], + }, +]; + +/** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */ +const SLOPE_LABELS = ["거 리", "면 적"]; + +/** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */ +const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; +const PAIR_LABELS = ["단면적", "입 적"]; + +const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols)); + +/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */ +function stationLabel(chainage: number, interval = 20): string { + const no = Math.floor(chainage / interval); + const plus = chainage - no * interval; + const rounded = Math.round(plus * 100) / 100; + return rounded === 0 ? `NO.${no}` : `NO.${no}+${rounded}`; +} + +function cell(value: number | undefined, digits: number): string { + if (value === undefined || value === null || Number.isNaN(value)) return ""; + if (value === 0) return ""; + return value.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function buildHead(): HTMLTableSectionElement { + const head = document.createElement("thead"); + const r1 = document.createElement("tr"); + const r2 = document.createElement("tr"); + const r3 = document.createElement("tr"); + + for (const group of GROUPS) { + const span = group.sub.reduce((n, s) => n + s.cols.length, 0); + if (group.label) { + const th = document.createElement("th"); + th.colSpan = span; + th.textContent = group.label; + r1.append(th); + for (const sub of group.sub) { + const th2 = document.createElement("th"); + th2.colSpan = sub.cols.length; + th2.textContent = sub.label; + r2.append(th2); + const labels = sub.cols.length === 3 ? TRIPLE_LABELS : PAIR_LABELS; + sub.cols.forEach((_, index) => { + const th3 = document.createElement("th"); + th3.textContent = labels[index] ?? ""; + r3.append(th3); + }); + } + continue; + } + // 대분류가 없는 열(측점·거리·보정량계·유용토·…)은 세 줄을 하나로 합친다. + for (const sub of group.sub) { + const th = document.createElement("th"); + th.colSpan = sub.cols.length; + th.rowSpan = 3; + th.textContent = sub.label; + r1.append(th); + } + } + + // 사면 4계열 — 대분류 / 면(성토·절토) / (거리·면적) 3단으로 같은 모양을 이어 붙인다. + for (const group of SLOPE_GROUPS) { + const th = document.createElement("th"); + th.colSpan = group.faces.length * 2; + th.textContent = group.label; + r1.append(th); + for (const face of group.faces) { + const th2 = document.createElement("th"); + th2.colSpan = 2; + th2.textContent = face.label; + r2.append(th2); + for (const label of SLOPE_LABELS) { + const th3 = document.createElement("th"); + th3.textContent = label; + r3.append(th3); + } + } + } + + head.append(r1, r2, r3); + return head; +} + +function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement { + const body = document.createElement("tbody"); + const columns = flatColumns(); + const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row])); + + for (const row of rows) { + const tr = document.createElement("tr"); + columns.forEach((column, index) => { + const td = document.createElement("td"); + td.textContent = + index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits); + if (index === 0) td.className = "b08-grid__station"; + tr.append(td); + }); + + const slopeRow = slopeByChainage.get(row.chainage_m); + // 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b). + if (slopeRow?.unclosed) tr.classList.add("is-unclosed"); + for (const group of SLOPE_GROUPS) { + for (const face of group.faces) { + for (const source of [slopeRow?.lengths, slopeRow?.areas]) { + const td = document.createElement("td"); + td.textContent = cell(source?.[face.key], 1); + tr.append(td); + } + } + } + body.append(tr); + } + return body; +} + +function buildFoot(totals: Record, slope?: SlopeTable): HTMLTableSectionElement { + const foot = document.createElement("tfoot"); + const tr = document.createElement("tr"); + flatColumns().forEach((column, index) => { + const td = document.createElement("td"); + if (index === 0) td.textContent = "계"; + else if (column.sum) td.textContent = cell(totals[column.key], column.digits); + tr.append(td); + }); + // 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다. + for (const group of SLOPE_GROUPS) { + for (const face of group.faces) { + tr.append(document.createElement("td")); + const td = document.createElement("td"); + td.textContent = cell(slope?.totals?.[face.key], 1); + tr.append(td); + } + } + foot.append(tr); + return foot; +} + +/** 잘린 측점 안내 — 한 덩어리로 묶고, 목록은 접어 둔다. + * + * 왜 붉은 오류가 아닌가 + * 실측 발생률이 21~26 %(랩탑 route 169 는 22/105, 이 노선은 17/65)라 **늘 뜨는 안내**다. + * 매번 요란하면 곧 무시당한다. 그래서 **주의 표시 + 접히는 목록**으로 둔다. + * + * 왜 한 덩어리인가 + * 절·성토 면적 · 사면적 · 사면길이가 **전부 같은 사유로** 잘린다. 항목마다 따로 띄우면 + * 사용자가 세 번 읽게 된다. + * + * 왜 안 넓히나 (B06 담당 확인, 2026-09-07) + * 미교차의 절반 이상이 계곡·절벽처럼 **지형이 설계 사면에서 멀어지는 자리**라 반폭을 + * 늘려도 영원히 안 닫힌다. 닫히는 쪽도 중앙값 +3m 인데 꼬리가 +292m 이라 전역 확대는 + * 값이 안 나온다. 그래서 경고로 대체한다(2026-09-03 사용자 확정). + */ +function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null { + const stations = slope.unclosed_stations ?? []; + if (!stations.length) return null; + + const box = document.createElement("details"); + box.className = "b08-grid__warning"; + + const summary = document.createElement("summary"); + summary.className = "b08-grid__warning-summary"; + summary.textContent = + `주의 — ${stations.length}개 측점에서 사면이 원지반을 만나지 못했습니다. ` + + "그 측점의 절·성토 면적 · 사면길이 · 사면적이 함께 잘려 있어 실제보다 작습니다."; + box.append(summary); + + const list = document.createElement("div"); + list.className = "b08-grid__warning-list"; + for (const chainage of stations) { + const link = document.createElement("button"); + link.type = "button"; + link.className = "b08-grid__warning-station"; + link.textContent = stationLabel(chainage); + link.addEventListener("click", () => { + const row = table.querySelector( + `tbody tr:nth-child(${slopeRowIndex(slope, chainage) + 1})`, + ); + row?.scrollIntoView({ block: "center", behavior: "smooth" }); + row?.classList.add("is-highlighted"); + window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600); + }); + list.append(link); + } + box.append(list); + return box; +} + +function slopeRowIndex(slope: SlopeTable, chainage: number): number { + return slope.rows.findIndex((row) => row.chainage_m === chainage); +} + +/** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */ +export function renderEarthworkGrid(table: EarthworkTable): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `측점 ${table.station_count}곳 · 평균단면적법`; + wrap.append(caption); + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table"; + element.append( + buildHead(), + buildBody(table.rows, table.slope), + buildFoot(table.totals, table.slope), + ); + + if (table.slope) { + const notice = buildUnclosedNotice(table.slope, element); + if (notice) wrap.append(notice); + } + scroller.append(element); + wrap.append(scroller); + return wrap; +} diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts new file mode 100644 index 00000000..fb8fe14d --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid_Style.ts @@ -0,0 +1,196 @@ +/* ============================================================================= + * B08_Quantity_UI_EarthworkGrid_Style.ts + * 토적표 그리드 스타일. 한 번만 주입한다. + * + * 실무 산출서와 눈으로 대조되는 것이 이 표의 목적이라, 장식보다 **줄·칸이 또렷한 것**을 + * 우선한다. 숫자는 등폭으로 두어 자릿수가 세로로 맞는다. + * ========================================================================== */ + +const STYLE_ID = "b08-earthwork-grid-style"; + +/* 색은 전부 프로젝트 테마 변수를 쓴다 — 어두운 테마에서 머리글이 묻히지 않아야 한다. + 글자색을 배경과 함께 지정하는 까닭이 그것이다(배경만 주면 상속색이 배경에 잠긴다). */ +const CSS = ` +.b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; } + +/* 표 안에서 고르는 칸 — 관급/사급처럼 **줄마다 갈리는 값**을 여기서 정한다. */ +.b08-grid__select { + width: 100%; + min-width: 5.5rem; + padding: 0.15rem 0.25rem; + font: inherit; + color: var(--color-text); + background: var(--color-surface-raised); + border: 1px solid var(--color-border, rgba(128, 128, 128, 0.4)); + border-radius: 3px; +} +.b08-grid__select:disabled { + opacity: 0.45; /* 사급 줄의 설치 주체 — 뜻이 없으므로 흐리게 둔다 */ +} +/* 만진 줄은 표시가 남는다 — 무엇을 바꿨는지 보여야 한다. */ +.b08-grid__table td.is-changed { + box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf); +} + +/* 잠정값 안내 — 금액에 걸리는 값이 조용히 기본으로 돌지 않게. */ +.b08-quantity__notice { + margin: 0.35rem 0 0; + padding: 0.4rem 0.5rem; + font-size: 0.85em; + line-height: 1.4; + color: var(--color-text); + background: var(--color-surface-raised); + border-left: 3px solid var(--color-warning, #c08a3e); + border-radius: 3px; +} + +.b08-grid__caption { + margin: 0; + font-size: 12px; + color: var(--color-text-secondary); +} + +/* 열이 많아 화면을 넘친다 — 표만 가로로 구르고 페이지 몸통은 안 구른다. */ +.b08-grid__scroll { overflow: auto; flex: 1 1 auto; min-height: 0; } + +.b08-grid__table { + border-collapse: collapse; + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--color-text-body); +} + +.b08-grid__table th, +.b08-grid__table td { + border: 1px solid var(--color-border); + padding: 2px 8px; + text-align: right; +} + +.b08-grid__table thead th { + position: sticky; + top: 0; + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); + text-align: center; + z-index: 1; +} + +/* 측점 열은 왼쪽에 붙어 있어야 가로로 굴러도 어느 줄인지 보인다. */ +.b08-grid__station { + position: sticky; + left: 0; + background: var(--color-surface); + color: var(--color-text); + text-align: left; + font-weight: var(--font-weight-medium, 500); +} + +.b08-grid__table tfoot td { + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); +} + +/* 잘린 측점 안내 — 발생률이 21~26 % 로 늘 뜨는 것이라 붉은 오류가 아니라 **주의**로 둔다. + 요란하면 곧 무시당한다. 목록은 접어 두고 필요할 때만 편다. */ +.b08-grid__warning { + padding: 6px 10px; + font-size: 12px; + color: var(--color-text-body); + background: var(--color-surface-raised); + border-left: 3px solid var(--color-text-muted); +} + +.b08-grid__warning-summary { cursor: pointer; } + +.b08-grid__warning-list { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; + padding-top: 6px; +} + +.b08-grid__warning-station { + font-size: 11px; + padding: 0 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + font-variant-numeric: tabular-nums; +} + +/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다 — 눈에 띄되 요란하지 않게. */ +.b08-grid__table tbody tr.is-unclosed .b08-grid__station { + border-left: 3px solid var(--color-text-muted); +} + +.b08-grid__table tbody tr.is-highlighted td { + background: var(--color-royal-amethyst, #d8ccff); + color: #1b2220; +} + +.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); } + +.b08-quantity__tab { + padding: 4px 12px; + font-size: 13px; + border: 1px solid var(--color-border); + border-bottom: none; + background: var(--color-surface-raised); + color: var(--color-text-secondary); + cursor: pointer; +} + +.b08-quantity__tab.is-active { + background: var(--color-surface); + color: var(--color-text); + font-weight: var(--font-weight-medium, 600); +} + +.b08-quantity__body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 0; flex: 1 1 auto; } +.b08-quantity__pane { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; } + +/* 집계·운반표는 열이 적어 왼쪽 정렬이 읽기 좋다 — 숫자 칸만 오른쪽으로 둔다. */ +.b08-grid__table--summary th, +.b08-grid__table--summary td { text-align: left; } +.b08-grid__table--summary td:nth-child(5), +.b08-grid__table--summary td:nth-child(4) { text-align: right; } +.b08-grid__unit { text-align: center; } +.b08-grid__note { white-space: normal; max-width: 26rem; } + +/* 「내역 제외」 같은 표시 — 규칙이 코드에만 있으면 잊힌다. 화면에 남긴다. */ +.b08-grid__tag { + display: inline-block; + margin-right: 4px; + padding: 0 6px; + font-size: 11px; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); +} + +.b08-quantity__input { + width: 5rem; + font-size: 12px; + text-align: right; + font-variant-numeric: tabular-nums; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); +} +.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); } +.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; } +.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } +`; + +/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */ +export function injectEarthworkGridStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.append(style); +} diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts new file mode 100644 index 00000000..a288291d --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -0,0 +1,456 @@ +/* ============================================================================= + * B08_Quantity_UI_MaterialGrid.ts + * 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7). + * + * 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다. + * + * ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은 + * 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고, + * 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다. + * + * ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다. + * ========================================================================== */ + +export interface MaterialRow { + name: string; + unit: string; + net_amount: number; + surcharge_pct: number | null; + total_amount: number; + supply: string; + supply_label: string; + install_by: string | null; + install_by_label: string; + note: string; + sources: string[]; +} + +export interface MaterialTable { + columns: string[]; + rows: MaterialRow[]; + surcharge_applied: boolean; + surcharge_dataset: { effective_date: string; source: Record }; + missing_rate_materials: string[]; + missing_supply_materials: string[]; + missing_install_by_materials: string[]; + amount_spread: Record; + double_count_warnings: string[]; + skipped_by_destination: Record; + row_count: number; +} + +export interface UnitQuantityStructure { + structure_id: string | null; + type_id: string; + name: string; + length_m: number; + height_m: number; + notes: string[]; + components: { + name: string; + unit: string; + amount: number; + destination: string; + basis: string; + /** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */ + basis_kind?: string; + source?: string; + /** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */ + reuse_count?: number | null; + reuse_note?: string; + }[]; +} + +/** 묶음으로 서는 구조물의 조각. 품셈에 그 이름의 공종이 없어 여러 공종으로 나뉜다. */ +export interface CompositePart { + code: string | null; + name?: string; + unit?: string; + quantity: number | null; + basis_kind?: string | string[] | null; + /** 철근 갈래(간단/보통/복잡/매우복잡) — 품셈 원문이 정한다. */ + kind?: string | null; + kind_basis?: string; + not_ready?: boolean; + why?: string; + /** 물량은 섰으나 일부 몫이 빠진 조각 — 「다 섰다」로 오해하지 않게 함께 보인다. */ + incomplete_note?: string; +} + +export interface MaterialResponse { + unit_quantity: { + structures: UnitQuantityStructure[]; + totals: { name: string; unit: string; amount: number; destination: string }[]; + surcharge_applied: boolean; + mix_components_found: string[]; + structure_count: number; + }; + material: MaterialTable; + skipped_structures: string[]; + structure_count: number; + /** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */ + composite?: { + name: string; + parts: CompositePart[]; + /** 못 채운 조각 — 「단가 없음」과 「물량 없음」을 가르려고 사유를 구조로 받는다. */ + not_ready?: { code: string | null; reason: string }[] | null; + }[]; +} + +/** 거푸집·동바리 안내에 쓰는 값. */ +export interface FormworkInfo { + formwork_notes?: string[]; + formwork_reuse_missing?: string[]; + shoring?: { applicable: boolean; reason: string; pending_types: string[] }; + /** 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다. */ + pending_choices?: { + label: string; + default?: unknown; + where?: string; + effect?: string; + scope?: string; + }[]; +} + +/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */ +const DESTINATION_LABELS: Record = { + earthwork: "토공 합산", + material: "자재총괄", + unit_price: "일위대가", +}; + +function num(value: number | null | undefined, digits: number): string { + if (value === undefined || value === null || Number.isNaN(value)) return ""; + return value.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function textCell(text: string, className?: string): HTMLTableCellElement { + const td = document.createElement("td"); + td.textContent = text; + if (className) td.className = className; + return td; +} + +function headRow(labels: string[]): HTMLTableSectionElement { + const head = document.createElement("thead"); + const tr = document.createElement("tr"); + for (const label of labels) { + const th = document.createElement("th"); + th.textContent = label; + tr.append(th); + } + head.append(tr); + return head; +} + +/** 못 채운 조각의 사유를 사람이 읽는 줄로. 코드가 있으면 앞에 붙인다. */ +function reasons(items?: { code: string | null; reason: string }[] | null): string[] { + return (items ?? []).map((item) => (item.code ? `${item.code} ${item.reason}` : item.reason)); +} + +/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */ +function warning(title: string, items: string[]): HTMLElement | null { + if (!items.length) return null; + const element = document.createElement("p"); + element.className = "b08-grid__caption b08-grid__caption--warn"; + element.textContent = `${title}: ${items.join(" · ")}`; + return element; +} + +/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */ +const SUPPLY_OPTIONS = [ + { value: "unknown", label: "미분류" }, + { value: "contractor_supplied", label: "사급" }, + { value: "owner_supplied", label: "관급" }, +]; +const INSTALL_BY_OPTIONS = [ + { value: "", label: "미지정" }, + { value: "contractor", label: "도급자설치" }, + { value: "owner", label: "관 직접설치" }, +]; + +export interface SupplyChoice { + supply: string; + install_by: string | null; +} + +export interface MaterialGridOptions { + /** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */ + choices: Record; + onChange: () => void; +} + +/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */ +function choiceCell( + value: string, + options: { value: string; label: string }[], + disabled: boolean, + onChange: (value: string) => void, +): HTMLTableCellElement { + const td = document.createElement("td"); + const select = document.createElement("select"); + select.className = "b08-grid__select"; + for (const option of options) { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.append(element); + } + select.value = value; + select.disabled = disabled; + select.addEventListener("change", () => { + onChange(select.value); + td.classList.add("is-changed"); + }); + td.append(select); + return td; +} + +/** 값의 크기 요약 — 자릿수가 어긋난 것은 사람이 훑어야 보인다. */ +function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTMLElement | null { + const units = Object.keys(spread || {}); + if (!units.length) return null; + const element = document.createElement("p"); + element.className = "b08-grid__caption"; + element.textContent = + title + + " " + + units + .map((unit) => { + const s = spread[unit]; + return `${unit} 최소 ${num(s.min, 2)} · 중앙 ${num(s.median, 2)} · 최대 ${num(s.max, 2)}`; + }) + .join(" / "); + return element; +} + +/** 자재총괄표 — 할증이 붙는 유일한 자리. */ +export function renderMaterialGrid( + table: MaterialTable, + options?: MaterialGridOptions, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + const edition = table.surcharge_dataset?.effective_date || "판 미상"; + caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`; + wrap.append(caption); + + const spread = spreadLine(table.amount_spread, "물량 크기:"); + if (spread) wrap.append(spread); + + for (const notice of [ + warning("⚠ 중복 할증 위험", table.double_count_warnings), + warning("할증률 미확보", table.missing_rate_materials), + warning("관급구분 미분류", table.missing_supply_materials), + warning("설치 주체 미지정(관급)", table.missing_install_by_materials), + ]) { + if (notice) wrap.append(notice); + } + + if (!table.rows.length) { + const empty = document.createElement("p"); + empty.className = "b08-quantity__message"; + empty.textContent = "구조물에서 나온 자재가 없음 — 구조물을 먼저 배치할 것"; + wrap.append(empty); + return wrap; + } + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + element.append(headRow(table.columns)); + + const body = document.createElement("tbody"); + for (const row of table.rows) { + const tr = document.createElement("tr"); + tr.append(textCell(row.name, "b08-grid__station")); + tr.append(textCell(row.unit, "b08-grid__unit")); + tr.append(textCell(num(row.net_amount, 2))); + // 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다. + tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0))); + tr.append(textCell(num(row.total_amount, 2))); + if (options) { + // 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정). + const chosen = options.choices[row.name] ?? { + supply: row.supply, + install_by: row.install_by, + }; + const installCell = choiceCell( + chosen.install_by ?? "", + INSTALL_BY_OPTIONS, + chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다 + (value) => { + const current = options.choices[row.name] ?? chosen; + options.choices[row.name] = { supply: current.supply, install_by: value || null }; + options.onChange(); + }, + ); + tr.append( + choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => { + const current = options.choices[row.name] ?? chosen; + const next = { + // 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다. + supply: value, + install_by: value === "owner_supplied" ? (current.install_by ?? null) : null, + }; + options.choices[row.name] = next; + // ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도 + // 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리). + const select = installCell.querySelector("select") as HTMLSelectElement | null; + if (select) { + select.disabled = value !== "owner_supplied"; + select.value = next.install_by ?? ""; + } + options.onChange(); + }), + ); + tr.append(installCell); + } else { + tr.append(textCell(row.supply_label)); + tr.append(textCell(row.install_by_label)); + } + tr.append(textCell(row.note, "b08-grid__note")); + body.append(tr); + } + + element.append(body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} + +/** 구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. */ +export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + const unit = response.unit_quantity; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `구조물 ${unit.structure_count}개 · 치수는 구조물 정본(B05)에서 · 할증 전 값`; + wrap.append(caption); + + // ㉢ 배합이 섞였으면 화면에도 뜬다 — 코드 검사만으로는 사람이 모른다. + const mixed = warning("⚠ 배합 성분이 섞였음(B09 일위대가와 이중계상)", unit.mix_components_found); + if (mixed) wrap.append(mixed); + const skipped = warning("건너뛴 구조물", response.skipped_structures); + if (skipped) wrap.append(skipped); + + // 거푸집 사용횟수 — 값이 아니라 **몇 회짜리인지**를 알려 주는 자리(품셈 1-7-1). + const info = unit as unknown as FormworkInfo; + const reuse = warning("거푸집 사용횟수", info.formwork_notes ?? []); + if (reuse) wrap.append(reuse); + const reuseMissing = warning("사용횟수 미확보", info.formwork_reuse_missing ?? []); + if (reuseMissing) wrap.append(reuseMissing); + if (info.shoring && !info.shoring.applicable) { + // 「없음」을 0 으로 적지 않는다 — 대상이 없는 것과 값이 0 인 것은 다르다. + const line = document.createElement("p"); + line.className = "b08-grid__caption"; + line.textContent = `동바리: 대상 없음 — ${info.shoring.reason.replace(/\*\*/g, "")}`; + wrap.append(line); + } + + // ⚠ 값을 바꾸는 설계 조건인데 칸이 없는 것 — 「무엇을 정해야 하는지」만으로는 부족하고 + // **「정하면 얼마나 달라지는지」**까지 보여야 사용자가 판단한다. + for (const choice of info.pending_choices ?? []) { + const line = document.createElement("p"); + line.className = "b08-quantity__notice"; + const parts = [`⚠ 미확정: ${choice.label}`]; + if (choice.effect) parts.push(choice.effect.replace(/\*\*/g, "")); + if (choice.where) parts.push(`근거 ${choice.where}`); + if (choice.scope) parts.push(choice.scope.replace(/\*\*/g, "")); + line.textContent = parts.join(" · "); + wrap.append(line); + } + + // ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로 + // 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다. + for (const group of response.composite ?? []) { + // 조각이 하나도 없으면 「묶음 공종 — 」 빈 줄이 남는다. 사유만 보이는 것이 낫다. + if (!group.parts?.length) { + const blocked = warning("⚠ 묶음을 못 세움", reasons(group.not_ready)); + if (blocked) wrap.append(blocked); + continue; + } + const box = document.createElement("p"); + box.className = "b08-quantity__notice"; + const parts = group.parts.map((part) => { + const kind = part.kind ? `#${part.kind}` : ""; + const amount = + part.quantity === null || part.quantity === undefined + ? "-" + : `${num(part.quantity, 3)}${part.unit ?? ""}`; + const flag = part.not_ready ? " ⚠" : part.incomplete_note ? " ⚠부분" : ""; + return `${part.name ?? part.code}${kind} ${amount}${flag}`; + }); + box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`; + wrap.append(box); + const blocked = warning("⚠ 물량을 못 채운 조각", reasons(group.not_ready)); + if (blocked) wrap.append(blocked); + // ⚠ 값이 있는데 일부만 선 조각 — **부분 성공이 완전 실패보다 위험하다**. + const partial = warning( + "⚠ 일부 몫이 빠진 조각", + group.parts.filter((p) => p.incomplete_note).map((p) => `${p.name}: ${p.incomplete_note}`), + ); + if (partial) wrap.append(partial); + } + + if (!unit.structures.length) { + const empty = document.createElement("p"); + empty.className = "b08-quantity__message"; + empty.textContent = "배치된 구조물이 없음"; + wrap.append(empty); + return wrap; + } + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거", "출처"])); + + const body = document.createElement("tbody"); + for (const structure of unit.structures) { + const spec = `H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m`; + if (!structure.components.length) { + const tr = document.createElement("tr"); + tr.append(textCell(structure.name, "b08-grid__station")); + tr.append(textCell(spec)); + const note = textCell(structure.notes.join(" · "), "b08-grid__note"); + note.colSpan = 5; + tr.append(note); + body.append(tr); + continue; + } + let first = true; + for (const component of structure.components) { + const tr = document.createElement("tr"); + // 같은 구조물이 이어지면 이름을 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다. + tr.append(textCell(first ? structure.name : "", "b08-grid__station")); + tr.append(textCell(first ? spec : "")); + first = false; + tr.append(textCell(component.name)); + tr.append(textCell(component.unit, "b08-grid__unit")); + tr.append(textCell(num(component.amount, 3))); + tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination)); + tr.append(textCell(component.basis, "b08-grid__note")); + // ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야 + // 나중에 「이 값이 왜 이런가」를 되짚을 수 있다. + const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개"; + tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}회` : kind)); + body.append(tr); + } + } + + element.append(body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index c6463b8d..affaff1c 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,16 +2,34 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) * - * ⚠️ 본문 준비 중 — 워크플로우 셸 + 좌측 [확정] 버튼만 구성. + * 우측 = 실무 수량산출서의 시트를 탭으로 옮긴 것. 지금은 **토적표** 한 장이 서 있고 + * 나머지(토적집계·구조물위치·수량집계표·총괄집계·수리계산·운반거리)는 차례로 붙인다. * 확정 = 워크플로 stage 5(QUANTITY) 완료 처리 후 B09 설계도서로 이동. - * 수량 본문(B06 종횡단 기반 산출)은 후속 계획에서 구현한다. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showToast } from "@ui/ui_template_elements"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; -import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { + fetchWorkflowState, + goToWorkflowStage, + WORKFLOW_STEP_ROUTES, +} from "../A00_Common/b_workflow_nav"; +import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid"; +import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; +import { + renderHaulGrid, + renderPreparationGrid, + renderSummaryGrid, + type PreparationTable, +} from "./B08_Quantity_UI_SummaryGrid"; +import { + renderMaterialGrid, + renderUnitQuantityGrid, + type MaterialResponse, +} from "./B08_Quantity_UI_MaterialGrid"; /** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { @@ -29,15 +47,459 @@ async function confirmQuantityStage(projectId: string): Promise { } } -/** 좌측 패널: 준비 중 안내 + 하단 [확정] 액션 행 (다른 워크플로우 페이지와 동일 배치). */ -function buildQuantitySidePanel(projectId: string | null): HTMLElement { +/** 토적표를 받아 온다. 노선을 안 주면 워크플로가 보고 있는 최신 노선으로 나온다. */ +async function fetchEarthworkTable(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/earthwork-table`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`earthwork table failed: ${response.status}`); + return (await response.json()) as EarthworkTable; +} + +/** 구조물 원단위·자재총괄을 받아 온다. 한 번에 받는 까닭은 자재총괄이 원단위의 부분집합이라서다. */ +async function fetchMaterialSummary(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/material-summary`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`material summary failed: ${response.status}`); + return (await response.json()) as MaterialResponse; +} + +/** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */ +async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/settings`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rock_class_set: draft.rock_class_set ?? null, + rock_ratios_pct: draft.rock_ratios_pct, + application_ratios_pct: draft.application_ratios_pct, + // ⚠ 「안 정함」으로 되돌린 갈래까지 **통째로** 보낸다. 정한 것만 보내면 서버가 + // 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다. + rock_methods: draft.rock_methods, + material_supply: draft.material_supply, + concrete_placing_method: draft.concrete_placing_method, + // ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙). + topsoil_thickness_m: draft.topsoil_thickness_m, + }), + }, + ); + if (!response.ok) throw new Error(`quantity settings save failed: ${response.status}`); +} + +/** 좌측 패널의 한 줄 — 이름과 값. 산출 조건을 읽기 전용으로 보인다. */ +function field(label: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const amount = document.createElement("span"); + amount.className = "b08-quantity__field-value"; + amount.textContent = value; + row.append(name, amount); + return row; +} + +/** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */ +const RATIO_LABEL_KEYS: Record = { + fill_slope_compaction: "B08_Quantity_Ratio_FillCompaction", + seed_spray_fill: "B08_Quantity_Ratio_SeedFill", + seed_spray_cut: "B08_Quantity_Ratio_SeedCut", + obstacle_removal: "B08_Quantity_Ratio_TreeRemoval", +}; + +function ratioLabel(key: string): string { + const localeKey = RATIO_LABEL_KEYS[key]; + return localeKey ? L(localeKey) : key; +} + +/** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */ +function numberField(label: string, value: number, onInput: (value: number) => void): HTMLElement { + const row = document.createElement("label"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.className = "b08-quantity__input"; + input.min = "0"; + input.step = "1"; + input.value = String(value); + // 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장). + input.addEventListener("input", () => onInput(Number(input.value))); + row.append(name, input); + return row; +} + +/** 비워 둘 수 있는 숫자 칸 — **빈 값은 「안 정함」**이고 0 과 다르다. + * + * ⚠ `numberField` 로 두면 빈 칸이 0 으로 읽혀 「두께 0m」와 「안 정함」이 같아진다. + * 표토제거처럼 **안 정하면 줄이 아예 안 서는** 값은 그 둘을 갈라야 한다. + */ +function optionalNumberField( + label: string, + value: number | null, + step: string, + onInput: (value: number | null) => void, +): HTMLElement { + const row = document.createElement("label"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const input = document.createElement("input"); + input.type = "number"; + input.className = "b08-quantity__input"; + input.min = "0"; + input.step = step; + input.placeholder = L("B08_Quantity_Unset_Placeholder"); + input.value = value === null || value === undefined ? "" : String(value); + // 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장). + input.addEventListener("input", () => { + const text = input.value.trim(); + onInput(text === "" ? null : Number(text)); + }); + row.append(name, input); + return row; +} + +/** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */ +const PLACING_LABELS: Record = { + ready_mixed: "레디믹스트", + machine_mixed: "기계비빔", + hand_mixed: "인력비빔", +}; + +/** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */ +function selectField( + label: string, + value: string, + options: { value: string; label: string }[], + onChange: (value: string) => void, +): HTMLElement { + const row = document.createElement("label"); + row.className = "b08-quantity__field"; + const name = document.createElement("span"); + name.textContent = label; + const select = document.createElement("select"); + select.className = "b08-quantity__input"; + for (const option of options) { + const element = document.createElement("option"); + element.value = option.value; + element.textContent = option.label; + select.append(element); + } + select.value = value; + // 자동저장은 만들지 않는다 — 고른 값은 캐시에만 남는다(CLAUDE.md 5장). + select.addEventListener("change", () => onChange(select.value)); + row.append(name, select); + return row; +} + +/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */ +const GROUND_LABELS: Record = { + soil: "토사", + ripping_rock: "리핑암", + blasting_rock: "발파암", +}; + +function groundLabel(kind: string): string { + return GROUND_LABELS[kind] ?? kind; +} + +/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */ +export interface SupplyChoice { + supply: string; + install_by: string | null; +} + +/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */ +interface DraftSettings { + rock_class_set?: string; + rock_ratios_pct: Record; + application_ratios_pct: Record; + // 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다. + rock_methods: Record; + // 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다. + concrete_placing_method: string; + // 표토 두께(m) — `null` 은 「안 정함」. 정해야 표토제거 줄이 선다(품셈 9-15 [주]② 의 T). + topsoil_thickness_m: number | null; + // 자재별 관급/사급 — 표 안에서 줄마다 고른 값. + material_supply: Record; + dirty: boolean; +} + +/** 개발 전용 「확정 없이 다음으로」 한 줄 — 단추 둘 + 지금 상태 안내. + * + * ⚠ **조용히 넘어가지 않는다.** 우회로 열린 상태면 「확정을 건너뛴 상태입니다」를 띄운다 — + * 안 그러면 다음 사람이 「왜 값이 없나」로 헤맨다. + * ⚠ **되돌리는 단추를 같은 줄에 둔다.** 되돌릴 길이 안 보이면 검증용 프로젝트가 굳는다. + */ +function devUnlockRow(projectId: string, reload: () => void): HTMLElement { + const row = document.createElement("div"); + row.className = "b08-quantity__dev"; + + const title = document.createElement("p"); + title.className = "b08-quantity__note"; + title.textContent = L("B08_Quantity_Dev_Title"); + row.append(title); + + const state = document.createElement("p"); + state.className = "b08-quantity__note"; + row.append(state); + + const paint = (): void => { + fetch(`/api/projects/${projectId}/dev/unlock`, { credentials: "include" }) + .then((response) => (response.ok ? response.json() : null)) + .then((body: { bypassed_stages?: number[] } | null) => { + const stages = body?.bypassed_stages ?? []; + state.textContent = stages.length + ? `${L("B08_Quantity_Dev_Bypassed")} (${stages.join(", ")})` + : L("B08_Quantity_Dev_Normal"); + }) + .catch(() => { + state.textContent = L("B08_Quantity_Dev_Normal"); + }); + }; + paint(); + + const call = (method: "POST" | "DELETE", button: HTMLButtonElement): void => { + button.disabled = true; + fetch(`/api/projects/${projectId}/dev/unlock`, { method, credentials: "include" }) + .then((response) => { + if (!response.ok) throw new Error(String(response.status)); + showToast(L("B08_Quantity_Dev_Done"), "success"); + paint(); + reload(); + }) + .catch(() => showToast(L("B08_Quantity_Dev_Failed"), "error")) + .finally(() => { + button.disabled = false; + }); + }; + + const unlock = createButton({ + label: L("B08_Quantity_Dev_Unlock"), + variant: "ghost", + onClick: () => call("POST", unlock), + }); + const relock = createButton({ + label: L("B08_Quantity_Dev_Relock"), + variant: "ghost", + onClick: () => call("DELETE", relock), + }); + const buttons = document.createElement("div"); + buttons.className = "b08-quantity__actions ui-sidebar-actions"; + buttons.append(unlock, relock); + row.append(buttons); + return row; +} + +/** 좌측 패널: 산출 조건 + 하단 [저장]·[확정] 액션 행. + * `reload` 는 저장 뒤 표를 다시 그리는 손잡이다 — 조건이 바뀌면 집계·운반 값이 달라진다. */ +function buildQuantitySidePanel( + projectId: string | null, + table: EarthworkTable | null, + draft: DraftSettings, + reload: () => void, +): HTMLElement { const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; - const note = document.createElement("p"); - note.className = "b08-quantity__pending-note"; - note.textContent = L("B08_Quantity_Side_Pending"); - panel.append(note); + // ── 개발 전용 「확정 없이 다음으로」 ──────────────────────────────────────── + // ⚠ **맨 위에 둔다.** 패널이 `overflow: hidden` 이라 아래에 붙이면 화면 밖으로 + // 밀려 **눌리지 않는다**(2026-09-08 화면에서 실제로 그랬다 — 단추 y=772, + // 패널 높이 740). 상태 경고이기도 하니 자리도 여기가 맞다. + // ⚠ **이것은 보조 문일 뿐이다.** 진짜 문은 서버가 `ENVIRONMENT` 로 막는다 + // (`common_util_dev_unlock`). 화면만 숨기면 API 는 그대로 뚫려 있다. + // ⚠ **계산을 대신 돌리지 않는다** — 워크플로 잠금만 푼다. 값이 비어 보이는 것은 + // 정상이고, 그것을 「미확보」로 보이는 것이 이 화면이 이미 하는 일이다. + if (import.meta.env.DEV && projectId) { + panel.append(devUnlockRow(projectId, reload)); + } + + // 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다. + panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value"))); + const entries = Object.entries(table?.conversion_factors ?? {}); + if (entries.length) { + panel.append(field(L("B08_Quantity_Side_Factors"), "")); + for (const [kind, value] of entries) { + // ⚠ 서버 키(`soil`·`ripping_rock`·`blasting_rock`)를 그대로 내보내지 않는다 — + // 2026-09-08 ㉕ 화면 통과에서 좌측 세 줄이 개발자 키로 떠 있었다(`soil_guard` 와 같은 병). + // 모르는 키는 **지어내지 않고** 그대로 보인다. + panel.append(field(groundLabel(kind), String((value as { compacted: number }).compacted))); + } + } + + // ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ── + const classes = [...(table?.summary?.rock_classes ?? [])]; + // ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도 + // 시공법을 정할 수 있어야 공종이 선다 — 그때만 칸을 하나 더 낸다. + const hasRockFallback = (table?.summary?.rows ?? []).some((row) => row.item === "암"); + if (hasRockFallback && !classes.includes("암")) classes.push("암"); + if (classes.length) { + panel.append(field(L("B08_Quantity_Side_RockRatios"), "")); + for (const name of classes) { + // 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다. + // 「토사」도 비율 칸을 두지 않는다 — 토사 물량은 토적표의 흙깎기 값이 그대로 서고, + // 비율은 **암 총량을 갈래로 나누는 데만** 쓰인다(집계 엔진 `_rock_split`). + // 칸을 두면 넣은 값이 조용히 버려져 「입력 합 60 %」 같은 안내가 뜬다(2026-09-08 통과). + if (name !== "암" && name !== "토사") { + panel.append( + numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => { + draft.rock_ratios_pct[name] = value; + draft.dirty = true; + }), + ); + } + // ⚠ 암 갈래는 **시공법까지 정해야** 공종이 갈린다 — 품셈이 긁어내기(암절취)와 + // 터뜨리기(발파암)를 다른 공종으로 두기 때문이다. 「토사」에는 안 붙인다. + if (name !== "토사") { + panel.append( + selectField( + ` ${name} ${L("B08_Quantity_Side_Method_Label")}`, + draft.rock_methods[name] ?? "", + [ + { value: "", label: L("B08_Quantity_Method_Unset") }, + { value: "ripping", label: L("B08_Quantity_Method_Ripping") }, + { value: "blasting", label: L("B08_Quantity_Method_Blasting") }, + ], + (value) => { + draft.rock_methods[name] = value; + draft.dirty = true; + }, + ), + ); + } + } + } + + // ── 반영률 — 기본 100 %. 실무 관측 80/50/80 은 기본값이 아니다(PLAN 8-11) ── + const ratios = table?.settings?.application_ratios_pct ?? {}; + if (Object.keys(ratios).length) { + panel.append(field(L("B08_Quantity_Side_Ratios"), "")); + for (const key of Object.keys(ratios)) { + panel.append( + numberField(ratioLabel(key), draft.application_ratios_pct[key] ?? 100, (value) => { + draft.application_ratios_pct[key] = value; + draft.dirty = true; + }), + ); + } + } + + // ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ── + // ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.** + // 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리. + panel.append(field(L("B08_Quantity_Side_Topsoil"), "")); + panel.append( + optionalNumberField( + L("B08_Quantity_Side_Topsoil_Label"), + draft.topsoil_thickness_m, + "0.01", + (value) => { + draft.topsoil_thickness_m = value; + draft.dirty = true; + }, + ), + ); + + // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── + panel.append(field(L("B08_Quantity_Side_Placing"), "")); + panel.append( + selectField( + L("B08_Quantity_Side_Placing_Label"), + draft.concrete_placing_method, + [ + { value: "", label: L("B08_Quantity_Placing_Unset") }, + { value: "ready_mixed", label: L("B08_Quantity_Placing_Ready") }, + { value: "machine_mixed", label: L("B08_Quantity_Placing_Machine") }, + { value: "hand_mixed", label: L("B08_Quantity_Placing_Hand") }, + ], + (value) => { + draft.concrete_placing_method = value; + draft.dirty = true; + }, + ), + ); + const placing = ( + table as unknown as { + concrete_placing?: { + method: string; + is_default: boolean; + price_hint?: { basis?: string; values?: Record }; + }; + } + ).concrete_placing; + if (placing) { + // ⚠ 방식 이름을 **늘** 값 옆에 보인다 — 코드(`12-01-01`)만으로는 무엇을 쓰는지 모른다. + const label = PLACING_LABELS[placing.method] ?? placing.method; + panel.append( + field( + L("B08_Quantity_Placing_Current"), + placing.is_default ? `${label} (${L("B08_Quantity_Placing_Default_Tag")})` : label, + ), + ); + // 2026-09-08 ㉙: 인계가 **타설 공종 줄을 실제로 세운다.** 겹치지 않는 것이 확인됐다 — + // 품셈 12-1-1 표는 직종·품만 주고 재료를 안 줘서, 품은 이 줄 · 재료는 자재 쪽이다. + // ⚠ 돌쌓기 뒤채움(채움콘크리트)은 뺐다 — 그 공종 품에 이미 들어 있을 수 있다. + // ⚠ **기본값일 때는 싣지 않는다** — 바로 아래 「기본값으로 계산 중」 경고가 더 많은 것을 + // 말하는데, 둘을 겹쳐 실으면 긴 문구가 그 경고를 화면 밖으로 밀어낸다 + // (2026-09-08 ㉕ 화면 통과에서 실제로 잘려 있었다). + if (!placing.is_default) { + const applied = document.createElement("p"); + applied.className = "b08-quantity__note"; + applied.textContent = L("B08_Quantity_Placing_NotApplied"); + panel.append(applied); + } + } + // ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고 + // B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫). + const hint = placing?.price_hint; + if (hint?.values) { + const line = document.createElement("p"); + line.className = "b08-quantity__notice"; + const parts = Object.entries(hint.values).map( + ([key, value]) => + `${PLACING_LABELS[key] ?? key} ${Math.round(value).toLocaleString("ko-KR")}원`, + ); + line.textContent = `${L("B08_Quantity_Placing_Hint")} ${hint.basis ?? ""} — ${parts.join(" · ")}`; + panel.append(line); + } + if (placing?.is_default) { + // 「확인 필요」만 있으면 무엇을 정해야 하는지 모른다 — **지금 무엇으로 돌고 있는지**를 함께 적는다. + const notice = document.createElement("p"); + notice.className = "b08-quantity__notice"; + notice.textContent = L("B08_Quantity_Placing_Default_Notice"); + panel.append(notice); + } + + const saveButton = createButton({ + label: L("B08_Quantity_Btn_Save"), + variant: "ghost", + onClick: () => { + if (!projectId) { + showToast(L("B08_Quantity_Save_Failed"), "error"); + return; + } + saveButton.disabled = true; + saveQuantitySettings(projectId, draft) + .then(() => { + draft.dirty = false; + showToast(L("B08_Quantity_Save_Success"), "success"); + // 조건이 바뀌면 집계·운반 값이 달라진다 — 표를 다시 받아 그린다. + reload(); + }) + .catch(() => { + showToast(L("B08_Quantity_Save_Failed"), "error"); + saveButton.disabled = false; + }); + }, + }); const confirmButton = createButton({ label: L("B08_Quantity_Btn_Confirm"), @@ -62,20 +524,177 @@ function buildQuantitySidePanel(projectId: string | null): HTMLElement { const actions = document.createElement("div"); actions.className = "b08-quantity__actions ui-sidebar-actions"; - actions.append(confirmButton); + // [초기화]는 이번에 달지 않는다 — 5장의 [초기화]는 초기값(`initial_snapshot/`)을 작업본에 + // 덮어쓰는 것인데 설정에는 대응하는 초기값이 아직 없다. 재계산 단추로 오해될 자리다. + // TODO(미결) — 설정의 초기값을 무엇으로 볼지 사용자 확인 뒤에 붙인다. + actions.append(saveButton, confirmButton); panel.append(actions); return panel; } +/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */ +function buildQuantityBody( + table: EarthworkTable | null, + failed: boolean, + material: MaterialResponse | null, + draft: DraftSettings, +): HTMLElement { + const body = document.createElement("div"); + body.className = "b08-quantity__body"; + + const tabs = document.createElement("div"); + tabs.className = "b08-quantity__tabs"; + const pane = document.createElement("div"); + pane.className = "b08-quantity__pane"; + + const message = (text: string): HTMLElement => { + const element = document.createElement("p"); + element.className = "b08-quantity__message"; + element.textContent = text; + return element; + }; + + if (failed) { + body.append(tabs, message(L("B08_Quantity_Grid_Failed"))); + return body; + } + if (!table || !table.rows?.length) { + body.append(tabs, message(L("B08_Quantity_Grid_Empty"))); + return body; + } + + const sheets: { label: string; build: () => HTMLElement }[] = [ + { label: L("B08_Quantity_Tab_Earthwork"), build: () => renderEarthworkGrid(table) }, + { + label: L("B08_Quantity_Tab_Summary"), + build: () => + table.summary ? renderSummaryGrid(table.summary) : message(L("B08_Quantity_Grid_Empty")), + }, + { + label: L("B08_Quantity_Tab_Haul"), + build: () => + table.haul + ? renderHaulGrid(table.haul, Boolean(table.haul_available)) + : message(L("B08_Quantity_Haul_Missing")), + }, + { + label: L("B08_Quantity_Tab_Preparation"), + build: () => { + const preparation = (table as unknown as { preparation?: PreparationTable }).preparation; + return preparation + ? renderPreparationGrid(preparation) + : message(L("B08_Quantity_Grid_Empty")); + }, + }, + { + label: L("B08_Quantity_Tab_UnitQuantity"), + build: () => + material ? renderUnitQuantityGrid(material) : message(L("B08_Quantity_Material_Failed")), + }, + { + label: L("B08_Quantity_Tab_Material"), + build: () => + material + ? renderMaterialGrid(material.material, { + choices: draft.material_supply, + onChange: () => { + draft.dirty = true; + }, + }) + : message(L("B08_Quantity_Material_Failed")), + }, + ]; + + const buttons: HTMLButtonElement[] = []; + const show = (index: number): void => { + buttons.forEach((button, i) => button.classList.toggle("is-active", i === index)); + pane.replaceChildren(sheets[index].build()); + }; + sheets.forEach((sheet, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b08-quantity__tab"; + button.textContent = sheet.label; + button.addEventListener("click", () => show(index)); + buttons.push(button); + tabs.append(button); + }); + + body.append(tabs, pane); + show(0); + return body; +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export async function renderB08Quantity(root: HTMLElement): Promise { + injectEarthworkGridStyles(); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - await renderPendingWorkflow(root, { + + // 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다. + let table: EarthworkTable | null = null; + let material: MaterialResponse | null = null; + let failed = false; + if (projectId) { + try { + table = await fetchEarthworkTable(projectId); + } catch { + failed = true; + } + // 자재총괄은 따로 받는다 — 구조물이 없어도 토적표는 서야 하므로 실패를 옮기지 않는다. + try { + material = await fetchMaterialSummary(projectId); + } catch { + material = null; + } + } + + // 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장). + const stored = table?.settings ?? {}; + const draft: DraftSettings = { + rock_class_set: stored.rock_class_set, + rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) }, + application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, + rock_methods: { ...((stored.rock_methods ?? {}) as Record) }, + concrete_placing_method: (stored.concrete_placing_method as string) ?? "", + topsoil_thickness_m: (stored.topsoil_thickness_m as number | null) ?? null, + material_supply: { ...((stored.material_supply ?? {}) as Record) }, + dirty: false, + }; + const reload = (): void => { + root.replaceChildren(); + void renderB08Quantity(root); + }; + // 저장 안 한 값이 조용히 사라지지 않게 나갈 때 알린다 — 이 구조의 대가다. + const warnUnsaved = (event: BeforeUnloadEvent): void => { + if (!draft.dirty) return; + event.preventDefault(); + event.returnValue = L("B08_Quantity_Unsaved"); + }; + window.addEventListener("beforeunload", warnUnsaved); + + let workflowState: Awaited> | undefined; + if (projectId) { + try { + workflowState = await fetchWorkflowState(projectId); + } catch { + /* 조회 실패 시 stages 미전달 → 전체 이동 허용 (다른 워크플로 페이지와 같음) */ + } + } + + const layout = createWorkflowLayout({ title: L("B08_Quantity_Title"), steps: workflowSteps(), activeStep: 5, - leftPanel: buildQuantitySidePanel(projectId), + leftPanel: buildQuantitySidePanel(projectId, table, draft, reload), + mainContent: buildQuantityBody(table, failed, material, draft), + stages: workflowState?.stages, + currentStage: workflowState?.current_stage, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex: number) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, }); + root.append(layout.root); } diff --git a/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts new file mode 100644 index 00000000..b4c03e32 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_SummaryGrid.ts @@ -0,0 +1,261 @@ +/* ============================================================================= + * B08_Quantity_UI_SummaryGrid.ts + * 토공집계표·운반거리 그리드 (PLAN 8-11·8-3). + * + * 토공집계표 열은 거창 실무 시트 그대로 — 구분·공종·규격·단위·계·비고. + * 비고에는 **설계자가 정한 값만** 남는다(반영률을 바꿨을 때·비율 합이 100 이 아닐 때). + * 기본값 그대로면 비워 둔다 — 안내가 매번 뜨면 잡음이 된다. + * + * ⚠ 무대(소운반 20m)는 집계에는 오르되 **내역 줄이 아니다**(품셈 1-2-7). 그 줄에 + * 「내역 제외」를 붙여 화면에서도 보이게 한다 — 규칙이 코드에만 있으면 잊힌다. + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export interface SummaryRow { + group: string; + item: string; + spec: string; + unit: string; + amount: number; + note: string; + in_bill: boolean; +} + +export interface SummaryTable { + columns: string[]; + rows: SummaryRow[]; + rock_classes: string[]; +} + +export interface HaulRow { + equipment: string; + ground: string; + volume_m3: number; + average_distance_m: number; + legs: number; + in_bill: boolean; +} + +export interface HaulTable { + rows: HaulRow[]; + legs: { + equipment: string; + ground: string; + volume_m3: number; + distance_m: number; + from_m: number; + to_m: number; + }[]; + bill_row_count: number; +} + +/** 운반수단 표기 — 서버 키를 실무 시트 문구로. */ +const HAUL_LABELS: Record = { + free_haul: "무대(종방향유용토)", + dozer: "도자운반", + dump_truck: "덤프운반", +}; + +function num(value: number | undefined, digits: number): string { + if (value === undefined || value === null || Number.isNaN(value) || value === 0) return ""; + return value.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function textCell(text: string, className?: string): HTMLTableCellElement { + const td = document.createElement("td"); + td.textContent = text; + if (className) td.className = className; + return td; +} + +/** 토공집계표 — 실무 시트와 같은 여섯 열. */ +export function renderSummaryGrid(table: SummaryTable): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const label of table.columns) { + const th = document.createElement("th"); + th.textContent = label; + headRow.append(th); + } + head.append(headRow); + + const body = document.createElement("tbody"); + let lastGroup = ""; + for (const row of table.rows) { + const tr = document.createElement("tr"); + // 같은 구분이 이어지면 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다. + tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station")); + lastGroup = row.group; + tr.append(textCell(row.item)); + tr.append(textCell(row.spec)); + tr.append(textCell(row.unit, "b08-grid__unit")); + tr.append(textCell(num(row.amount, row.unit === "㎥" ? 2 : 1))); + const note = textCell(row.note, "b08-grid__note"); + if (!row.in_bill) { + const tag = document.createElement("span"); + tag.className = "b08-grid__tag"; + tag.textContent = L("B08_Quantity_Haul_Excluded"); + note.prepend(tag); + } + tr.append(note); + body.append(tr); + } + + element.append(head, body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} + +/** 운반거리 — 내역 줄(가중평균)과 근거 줄을 나눠 보인다. */ +export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + if (!available || !table.rows.length) { + const message = document.createElement("p"); + message.className = "b08-quantity__message"; + message.textContent = L("B08_Quantity_Haul_Missing"); + wrap.append(message); + return wrap; + } + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `내역 줄 ${table.bill_row_count}개 · 근거 구간 ${table.legs.length}개 · 토량 가중평균`; + wrap.append(caption); + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const label of [ + "운반수단", + "지반유형", + "토량(㎥)", + "평균운반거리(m)", + "근거 구간", + "비고", + ]) { + const th = document.createElement("th"); + th.textContent = label; + headRow.append(th); + } + head.append(headRow); + + const body = document.createElement("tbody"); + for (const row of table.rows) { + const tr = document.createElement("tr"); + tr.append(textCell(HAUL_LABELS[row.equipment] ?? row.equipment, "b08-grid__station")); + tr.append(textCell(row.ground)); + tr.append(textCell(num(row.volume_m3, 2))); + tr.append(textCell(num(row.average_distance_m, 2))); + tr.append(textCell(String(row.legs))); + const note = textCell("", "b08-grid__note"); + if (!row.in_bill) { + const tag = document.createElement("span"); + tag.className = "b08-grid__tag"; + tag.textContent = L("B08_Quantity_Haul_Excluded"); + note.append(tag); + // 왜 빠지는지 같이 적는다 — 「제외」만 있으면 빠뜨린 것으로 오해된다. + note.append(document.createTextNode(" 품셈 1-2-7 소운반 20m 이내는 품에 포함")); + } + tr.append(note); + body.append(tr); + } + + element.append(head, body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} + +export interface PreparationRow { + group: string; + item: string; + unit: string; + amount: number | null; + status: string; + reason: string; + reference_amount?: number; + work_item_code: string | null; +} + +export interface PreparationTable { + columns: string[]; + rows: PreparationRow[]; + pending_count: number; + row_count: number; +} + +/** 준비공·사방공 — **못 서는 줄도 보인다.** + * + * 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도 + * 상태와 사유를 달아 그대로 세운다. + */ +export function renderPreparationGrid(table: PreparationTable): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b08-grid"; + + const caption = document.createElement("p"); + caption.className = "b08-grid__caption"; + caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}개`; + wrap.append(caption); + + const scroller = document.createElement("div"); + scroller.className = "b08-grid__scroll"; + const element = document.createElement("table"); + element.className = "b08-grid__table b08-grid__table--summary"; + + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const label of table.columns) { + const th = document.createElement("th"); + th.textContent = label; + headRow.append(th); + } + head.append(headRow); + + const body = document.createElement("tbody"); + let lastGroup = ""; + for (const row of table.rows) { + const tr = document.createElement("tr"); + tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station")); + lastGroup = row.group; + tr.append(textCell(row.item)); + tr.append(textCell(row.unit, "b08-grid__unit")); + // 값이 없으면 빈칸이 아니라 「-」 — 빈칸이면 0 으로 오해된다. + tr.append(textCell(row.amount === null ? "-" : num(row.amount, 2))); + tr.append(textCell(row.status)); + const note = textCell(row.reason.replace(/\*\*/g, ""), "b08-grid__note"); + if (row.reference_amount) { + note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`)); + } + tr.append(note); + body.append(tr); + } + + element.append(head, body); + scroller.append(element); + wrap.append(scroller); + return wrap; +} diff --git a/B08_Quantity/B08_Quantity_Wording.py b/B08_Quantity/B08_Quantity_Wording.py new file mode 100644 index 00000000..2ce69b9a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Wording.py @@ -0,0 +1,85 @@ +"""사용자에게 뜨는 문구 — **키 이름을 화면에 내보내지 않는다** (B08 ㉑). + +왜 있나 + 「`back_len_cm` 가 저장돼 있지 않아 갈래를 못 고름」처럼 **개발자 키 이름이 그대로** + 화면에 뜨던 자리가 있었다. 사용자는 그 이름을 모르고, 무엇을 해야 하는지도 알 수 없다. + (반영률 라벨이 서버 키로 뜨던 그 자리와 같은 병이다.) + +⚠ 「없다」만 말하지 않는다 — **어디서 채우면 풀리는지**를 함께 적는다 + 「원단위 미확보」로 끝나면 사용자는 손쓸 데를 모른다. **무엇이 채워지면 값이 서는지**가 + 같이 있어야 그 말이 쓸모 있다. 오늘 「표에 있는 규격을 함께 알린」 그 방식이다. + +⚠ 여기서 값을 바꾸지 않는다 + 문구만 다듬는 자리다. 판정·계산은 각 엔진이 하고, 이 모듈은 **그 결과를 사람 말로** 옮긴다. +""" + +from __future__ import annotations + +from typing import Any + +#: 구조물 종류의 사람 이름. 레지스트리 이름이 정본이고 여기는 **화면 문구가 필요할 때만** 쓴다. +#: ⚠ 레지스트리에 없는 이름을 지어내지 않는다 — 못 찾으면 원래 값을 그대로 보인다. +TYPE_LABELS = { + "masonry_wet": "돌쌓기(찰)", + "masonry_dry": "돌쌓기(메)", + "boulder_masonry": "큰돌쌓기", + "retaining_wall": "옹벽", + "soil_guard": "흙막이", + "pipe": "배수관", + "pipe_inlet_basin": "배수관 유입부 집수정", + "ford_pavement": "물넘이포장", + "ford_bridge": "세월교", + "box_culvert": "BOX암거", + # 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님. + "erosion_check": "골막이", + "bed_sill": "바닥막이", + "check_dam_small": "소형사방댐(복합형)", + "revetment": "기슭막이", +} + +#: 저장 제원 칸의 사람 이름 + **어디서 채우는지**. 키 이름을 화면에 내보내지 않기 위한 표. +OPTION_LABELS = { + "back_len_cm": ("돌 뒷길이(㎝)", "구조물 상세 입력"), + "stone_cm": ("돌 직경(㎝)", "구조물 상세 입력"), + "form": ("옹벽 형식", "구조물 상세 입력"), + "height_m": ("높이(m)", "구조물 배치"), + "length_m": ("연장(m)", "구조물 배치"), + "face_slope_ratio": ("전면 기울기", "아직 입력 칸이 없음"), +} + + +def type_label(type_id: str, names: dict[str, str] | None = None) -> str: + """구조물 이름 — 레지스트리 이름이 있으면 그것을 먼저 쓴다.""" + stored = (names or {}).get(type_id) + return stored or TYPE_LABELS.get(type_id, type_id) + + +def option_missing(option_key: str, type_id: str = "", names: dict[str, str] | None = None) -> str: + """「무엇이 없고 어디서 채우면 되는지」 한 줄. + + ⚠ 모르는 키면 **지어내지 않고** 키를 그대로 보인다 — 잘못된 안내가 없는 안내보다 나쁘다. + """ + label, where = OPTION_LABELS.get(option_key, (option_key, "")) + subject = f"{type_label(type_id, names)} " if type_id else "" + tail = f" — {where}에서 입력하면 값이 섭니다" if where else "" + return f"{subject}{label}이(가) 아직 입력되지 않았습니다{tail}" + + +def spec_missing( + type_id: str, known: list[dict[str, Any]], names: dict[str, str] | None = None +) -> str: + """규격이 표에 없을 때 — **표에 있는 규격을 함께** 보인다.""" + name = type_label(type_id, names) + if not known: + return f"{name}의 표준 물량 자료가 아직 없습니다 — 설계 표준도 확보가 필요합니다" + readable = " / ".join(_spec_text(spec) for spec in known) + return f"{name}의 이 규격은 자료에 없습니다 — 자료에 있는 규격: {readable}" + + +def _spec_text(spec: dict[str, Any]) -> str: + """규격 한 벌을 사람 말로. 키 이름 대신 라벨을 쓴다.""" + parts = [] + for key, value in spec.items(): + label = OPTION_LABELS.get(key, (key, ""))[0] + parts.append(f"{label} {value}") + return " · ".join(parts) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py new file mode 100644 index 00000000..fd50b7fc --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -0,0 +1,560 @@ +"""B09 원가계산 — ④ 예산내역서 조판 (PLAN 9-5 「B08 출력을 붙이는 자리」). + +**하는 일** — B08 인계 응답(공종 수량 + 자재)을 받아 **계층이 선 내역서 한 장**으로 +접는다. 수량은 B08 것을 그대로 쓰고(다시 세지 않는다), 단가는 ③ 일위대가에서 가져오며, +금액은 이 자리에서 `수량 × 단가` 로 만든다. + +**계층은 코드에 안 박는다** (PLAN 9-3 · STmate `BOQ11` 해부 결과). 공종 마스터가 이미 +`parent_code` · `level` · `sort_order`(256 간격)를 들고 있으므로, 쓰인 공종의 **조상만 +남긴 가지치기 나무**를 세우고 거기서 ITEM NO. 를 매긴다. 깊이를 코드 글자수로 세지 않는다 — +`FP-09-03-02` 가 3층이라는 보장이 없다. + +**빈칸을 지어내지 않는다** — 단가가 없는 줄, 관급/사급이 안 갈린 자재는 금액을 0 으로 +때우지 않고 `missing` 에 이름째 남긴다. 화면이 그것을 그대로 보인다. + +⚠ **`in_bill=false` 줄에는 단가를 붙이지 않는다** (PLAN 8-7 ㉡ 와 같은 성격). +보정량계·무대 같은 검산용 줄이라 금액을 매기면 같은 것을 두 번 세게 된다. 주석으로 +막지 않고 `check_excluded_rows_not_priced()` 가 수치로 멈춘다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_Guards import ( + OVERHEAD_TIER_WITHOUT_PIPE, + check_excluded_rows_not_priced, + check_drain_pipe_not_double_counted, + check_included_materials_not_listed, + check_free_haul_not_priced, + check_haul_volume_within_cut, +) +from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master +from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import ( + UnitPriceBuild, + cached_build, + find_variant_code, +) + +_ZERO = Decimal(0) + +#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인). +#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. +SUPPLY_UNKNOWN = "unknown" + +#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2). +SUPPLY_OWNER = "owner_supplied" + +#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.** +#: ⚠ `blocked_kind` 가 **`None` 인데 `in_bill=False`** 면 **막힌 줄이 아니다** — +#: 「다른 표에서 이미 섬」(벌목·지장목제거)이거나 「이 노선엔 없음」(사방 시설)이다. +#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙). +_NOT_OUR_ROW = "not_our_row" + +_BLOCKED_LABELS = { + _NOT_OUR_ROW: "여기서 세지 않는 줄", + "input_missing": "입력이 필요합니다", + "unit_data_missing": "원단위가 없습니다(우리가 만들 것)", + "formula_missing": "전개식이 없습니다(우리가 만들 것)", +} + + +class BillError(ValueError): + """내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다.""" + + +@dataclass(frozen=True) +class HandoffWorkItem: + """B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다.""" + + work_item_code: str | None + name: str + spec: str + unit: str + quantity: Decimal + in_bill: bool + #: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다. + haul_distance_m: Decimal | None = None + haul_equipment: str | None = None + in_bill_reason: str = "" + origin: str = "" + ground_class: str = "" + #: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**. + application_ratio_pct: Decimal | None = None + #: 반영률 적용 **전** 수량. 산출근거에만 쓴다. + quantity_gross: Decimal | None = None + #: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다. + application_ratio_breakdown: dict | None = None + quantity_breakdown: dict | None = None + #: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지. + composite_parts: tuple = () + #: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다. + composite_not_ready: tuple = () + structure_kind: str = "" + #: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다). + blocked_reason: str = "" + #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / + #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. + blocked_kind: str = "" + #: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의). + variant_axis: str = "" + variant_value: str = "" + #: 규격 갈래를 B08 이 판정해 보낸 것(「철근구조물」)과 그 근거 문구. + #: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다. + spec_class: str = "" + spec_class_basis: str = "" + + @property + def display_name(self) -> str: + return f"{self.name} {self.spec}".strip() + + +@dataclass(frozen=True) +class HandoffMaterial: + """B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정).""" + + material_name: str + spec: str + unit: str + net_amount: Decimal + total_amount: Decimal + supply_type: str + surcharge_pct: Decimal | None = None + surcharge_note: str = "" + install_by: str | None = None + source_structure: tuple[str, ...] = () + + @property + def display_name(self) -> str: + return f"{self.material_name} {self.spec}".strip() + + +@dataclass +class BillRow: + """내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다.""" + + item_no: str + level: int + code: str | None + name: str + spec: str = "" + unit: str = "" + quantity: Decimal | None = None + unit_price_krw: Decimal | None = None + amount_krw: Decimal | None = None + #: 3분할 — ⑤ 로 넘길 때 **뭉치지 않고** 성분 그대로 간다(PLAN 8-9 규칙 2). + material_krw: Decimal = _ZERO + labor_krw: Decimal = _ZERO + expense_krw: Decimal = _ZERO + is_group: bool = False + in_bill: bool = True + note: str = "" + + def as_dict(self) -> dict[str, Any]: + def money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + # 수량 소수자리는 **종목마다 다르다** (품셈 1-2-2). 값은 전정밀로 두고 **찍을 자리만** + # 함께 보낸다 — 자르는 것은 표를 그리는 쪽 몫이다(단수 규칙과 같은 원칙). + shown, digits = ( + (None, None) + if self.quantity is None + else round_quantity(self.quantity, self.name, self.unit, self.spec) + ) + return { + "item_no": self.item_no, + "level": self.level, + "code": self.code, + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "quantity": money(self.quantity), + #: 품셈 1-2-2 종목별 자리로 **반올림한** 표시값. 자리를 모르면 `None` 이고, + #: 그때 화면은 종전대로 찍는다(지어내지 않는다). + "quantity_shown": money(shown) if digits is not None else None, + "quantity_digits": digits, + "unit_price_krw": money(self.unit_price_krw), + "amount_krw": money(self.amount_krw), + "material_krw": str(self.material_krw), + "labor_krw": str(self.labor_krw), + "expense_krw": str(self.expense_krw), + "is_group": self.is_group, + "in_bill": self.in_bill, + "note": self.note, + } + + +@dataclass +class BillResult: + """④ 예산내역서 한 장.""" + + rows: list[BillRow] = field(default_factory=list) + #: 금액을 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다**. + missing: list[dict[str, str]] = field(default_factory=list) + #: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다. + excluded: list[BillRow] = field(default_factory=list) + #: 이 내역서에 쓰인 일위대가 코드 — ③ 단가산출서 번호를 매기는 차례가 된다. + used_unit_prices: list[str] = field(default_factory=list) + #: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다. + material_rows: list[BillRow] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + #: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다. + price_basis: Any = None + #: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」). + material_sheet: Any = None + + @property + def direct_material_krw(self) -> Decimal: + return sum((r.material_krw for r in self.rows if not r.is_group), _ZERO) + + @property + def direct_labor_krw(self) -> Decimal: + return sum((r.labor_krw for r in self.rows if not r.is_group), _ZERO) + + @property + def direct_expense_krw(self) -> Decimal: + return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO) + + @property + def body_total_krw(self) -> Decimal: + """내역서 **본체** 합계 — 줄마다 절사한 금액의 합. + + ⚠ 집계표(반올림) 합계와 원 단위로 어긋나는 것이 정상이다 + (`B09_Estimation_Rounding.SUMMARY_MISMATCH_NOTE`). + """ + return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO) + + +def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None: + if value is None or value == "": + return default + return Decimal(str(value)) + + +def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]: + """인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**""" + if "work_items" not in payload or "materials" not in payload: + raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.") + + work_items = [ + HandoffWorkItem( + work_item_code=row.get("work_item_code"), + name=row.get("name", ""), + spec=row.get("spec") or "", + unit=row.get("unit") or "", + quantity=_decimal(row.get("quantity")) or _ZERO, + in_bill=bool(row.get("in_bill", True)), + in_bill_reason=row.get("in_bill_reason") or "", + origin=row.get("origin") or "", + ground_class=row.get("ground_class") or "", + haul_distance_m=_decimal(row.get("haul_distance_m"), None), + haul_equipment=row.get("haul_equipment"), + # ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①). + application_ratio_pct=_decimal(row.get("application_ratio_pct"), None), + quantity_gross=_decimal(row.get("quantity_gross"), None), + application_ratio_breakdown=row.get("application_ratio_breakdown"), + quantity_breakdown=row.get("quantity_breakdown"), + composite_parts=tuple(row.get("composite_parts") or ()), + composite_not_ready=tuple(row.get("composite_not_ready") or ()), + structure_kind=row.get("structure_kind") or "", + blocked_reason=row.get("blocked_reason") or "", + blocked_kind=row.get("blocked_kind") or "", + variant_axis=row.get("variant_axis") or "", + variant_value=str(row.get("variant_value") or ""), + spec_class=row.get("spec_class") or "", + spec_class_basis=row.get("spec_class_basis") or "", + ) + for row in payload["work_items"] + ] + materials = [ + HandoffMaterial( + material_name=row.get("material_name", ""), + spec=row.get("spec") or "", + unit=row.get("unit") or "", + net_amount=_decimal(row.get("net_amount")) or _ZERO, + total_amount=_decimal(row.get("total_amount")) or _ZERO, + supply_type=row.get("supply_type") or SUPPLY_UNKNOWN, + surcharge_pct=_decimal(row.get("surcharge_pct"), None), + surcharge_note=row.get("surcharge_note") or "", + install_by=row.get("install_by"), + source_structure=tuple(row.get("source_structure") or ()), + ) + for row in payload["materials"] + ] + return work_items, materials + + +@dataclass(frozen=True) +class _MasterNode: + code: str + name: str + level: int + parent_code: str | None + sort_order: int + + +def _master_index(master: dict[str, Any]) -> dict[str, _MasterNode]: + return { + node["work_item_code"]: _MasterNode( + code=node["work_item_code"], + name=node.get("name", ""), + level=int(node.get("level", 1)), + parent_code=node.get("parent_code"), + sort_order=int(node.get("sort_order", 0)), + ) + for node in master.get("work_items", []) + if node.get("work_item_code") + } + + +def _ancestor_chain(code: str, index: dict[str, _MasterNode]) -> list[_MasterNode]: + """뿌리 → 자기 순서의 조상 사슬. **코드 글자수로 깊이를 세지 않는다.**""" + chain: list[_MasterNode] = [] + seen: set[str] = set() + cursor: str | None = code + while cursor and cursor in index and cursor not in seen: + seen.add(cursor) + node = index[cursor] + chain.append(node) + cursor = node.parent_code + chain.reverse() + return chain + + +def _number_of(path: tuple[int, ...]) -> str: + """ITEM NO. — 자리마다 1 부터. 「1」·「1-2」·「1-2-3」 모양.""" + return "-".join(str(n) for n in path) + + +from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402 + _composite_row, + _excluded_row, + _haul_price_of, + _leaf_row, + _material_row, +) + + +def build_bill( + payload: dict[str, Any], + *, + build: UnitPriceBuild | None = None, + master: dict[str, Any] | None = None, +) -> BillResult: + """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.""" + work_items, materials = parse_handoff(payload) + unit_prices = build or cached_build() + index = _master_index(master or load_work_item_master()) + result = BillResult() + + # ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ──────────────────────────────── + # 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다. + used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = [] + orphans: list[HandoffWorkItem] = [] + composites: list[HandoffWorkItem] = [] + for item in work_items: + if not item.in_bill: + # ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라 + # **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할 + # 줄」로 잘못 읽힌다. + row = _excluded_row(item) + result.excluded.append(row) + if item.blocked_reason: + # ⚠ 「다른 표에서 이미 섬」과 「입력이 필요함」을 **갈라** 싣는다. + kind = item.blocked_kind or _NOT_OUR_ROW + result.missing.append( + { + "name": item.display_name, + "unit": item.unit, + "quantity": str(item.quantity), + "reason": f"{_BLOCKED_LABELS.get(kind, '막힘')} — {item.blocked_reason}", + "blocked_kind": kind, + } + ) + continue + if (item.composite_parts or item.composite_not_ready) and not item.work_item_code: + # 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다 + # (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다. + composites.append(item) + continue + if not item.work_item_code or item.work_item_code not in index: + orphans.append(item) + continue + used.append(((), item, _ancestor_chain(item.work_item_code, index))) + + def sort_key(entry: tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]) -> tuple: + return tuple(node.sort_order for node in entry[2]) + + used.sort(key=sort_key) + + emitted: dict[str, str] = {} # 코드 → ITEM NO. + counters: dict[str, int] = {} # 부모 ITEM NO. → 마지막 번호 + + def next_number(parent_no: str) -> str: + counters[parent_no] = counters.get(parent_no, 0) + 1 + return f"{parent_no}-{counters[parent_no]}" if parent_no else str(counters[parent_no]) + + for _, item, chain in used: + parent_no = "" + # 조상 줄(머리글)을 먼저 세운다 — 이미 선 것은 다시 안 세운다. + for node in chain[:-1]: + if node.code in emitted: + parent_no = emitted[node.code] + continue + parent_no = next_number(parent_no) + emitted[node.code] = parent_no + result.rows.append( + BillRow( + item_no=parent_no, + level=node.level, + code=node.code, + name=node.name, + is_group=True, + ) + ) + leaf = chain[-1] + # ⚠ **잎 줄은 번호를 재사용하지 않는다.** 같은 공종코드가 지반·규격만 달리해 + # 두 번 올 수 있고(2026-09-08 실물: 도자운반 토사/리핑암 두 줄), 그때 번호를 + # 물려주면 ITEM NO. 가 겹쳐 어느 줄인지 못 가린다. 머리글만 물려준다. + item_no = next_number(parent_no) + emitted.setdefault(leaf.code, item_no) + result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result)) + + # ── 1-2) 묶음 줄 ────────────────────────────────────────────────────────── + for item in composites: + counters[""] = counters.get("", 0) + 1 + result.rows.append(_composite_row(str(counters[""]), item, unit_prices, result)) + + # ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ──────────────────────────────────── + for item in orphans: + # 구조물 줄은 사유가 다르다 — 품셈에 그 공종이 없어 **전개식(원단위)** 이 있어야 + # 조각으로 설 수 있다. 「코드가 없다」로만 적으면 무엇을 해야 하는지 안 보인다. + reason = ( + "구조물 전개식(원단위)이 없어 조각을 못 세웠습니다 — B08 원단위 필요." + if item.origin == "structure" + else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다." + ) + result.missing.append( + { + "name": item.display_name, + "unit": item.unit, + "quantity": str(item.quantity), + "reason": reason, + } + ) + + # ── 3) 자재 벌 ──────────────────────────────────────────────────────────── + for material in materials: + result.material_rows.append(_material_row(material, result)) + + # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── + check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) + + # ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가. + check_included_materials_not_listed( + work_item_codes=[row.code or "" for row in result.rows], + materials=[ + {"material_name": m.material_name, "source_structure": list(m.source_structure)} + for m in materials + ], + ) + + # ㉥ 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 파이프를 자재로 또 세지 않았는가. + # 지금은 제잡비를 늘 아랫단으로 붙여 `overhead_tier` 가 늘 미설치다 — 그래도 + # **부르는 자리를 비워 두지 않는다**(「있다」와 「돈다」는 다르다). 설계 조건이 + # 인계에 실리는 날 그 값만 바꿔 넣으면 바로 걸린다. + check_drain_pipe_not_double_counted( + overhead_tier=str(payload.get("overhead_tier") or OVERHEAD_TIER_WITHOUT_PIPE), + material_names=[m.material_name for m in materials], + ) + + # ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡). + # 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다. + # 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다. + haul_rows = [ + { + "equipment": item.haul_equipment, + "unit_price_krw": _haul_price_of(item, result), + } + for item in work_items + if item.haul_equipment + ] + check_free_haul_not_priced(haul_rows=haul_rows) + + # ㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가(같은 흙을 두 번 세지 않았는가). + cut_total = sum( + (item.quantity for item in work_items if "깎기" in item.name or "절취" in item.name), + _ZERO, + ) + haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO) + if cut_total > 0 and haul_total > 0: + check_haul_volume_within_cut( + haul_volume_total_m3=haul_total, + total_cut_volume_m3=cut_total, + ) + + # 자재대 — B08 수량·할증에 단가를 붙인다. 관급은 총원가 밖 별도 표기다. + from B09_Estimation.B09_Estimation_MaterialSheet import build_material_sheet + + result.material_sheet = build_material_sheet( + materials, + surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"), + ) + + # ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13). + from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis + + sheet = build_price_basis(result.used_unit_prices, unit_prices) + for row in result.rows: + if row.is_group or row.code is None or row.amount_krw is None: + continue + entry = sheet.by_unit_price(f"B-{row.code}") + if entry is not None: + row.note = " / ".join(part for part in (entry.label, row.note) if part) + result.price_basis = sheet + + if any(m.surcharge_pct is None for m in materials): + result.notes.append( + "자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. " + "할증은 자재총괄에서 한 번만 붙습니다 (PLAN 8-7 ㉠)." + ) + return result + + +def bill_summary(result: BillResult) -> dict[str, Any]: + """화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다.""" + return { + "rows": len(result.rows), + "detail_rows": sum(1 for r in result.rows if not r.is_group), + "group_rows": sum(1 for r in result.rows if r.is_group), + "excluded_rows": len(result.excluded), + "material_rows": len(result.material_rows), + "material_sheet": result.material_sheet.as_dict() if result.material_sheet else None, + "missing": result.missing, + "body_total_krw": str(result.body_total_krw), + "direct_material_krw": str(result.direct_material_krw), + "direct_labor_krw": str(result.direct_labor_krw), + "direct_expense_krw": str(result.direct_expense_krw), + "notes": result.notes, + } + + +def cost_input_from_bill(result: BillResult, **cost_input_kwargs): + """④ 내역서 합계를 ⑤ 원가계산서 입력으로 접어 넣는다. + + **뭉치지 않는다** — 재료·노무·경비 성분이 그대로 간다(PLAN 8-9 규칙 2). + ⑤ 표에 찍히는 자리이므로 **자원 집계표 규칙(반올림)** 으로 자른다. + """ + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput + + summary = OutputPlace.RESOURCE_SUMMARY + return CostInput( + direct_material_krw=round_at(result.direct_material_krw, summary), + direct_labor_krw=round_at(result.direct_labor_krw, summary), + direct_expense_krw=round_at(result.direct_expense_krw, summary), + **cost_input_kwargs, + ) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py new file mode 100644 index 00000000..fb2d04fe --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -0,0 +1,397 @@ +"""B09 원가계산 — ④ 예산내역서 **줄 만들기** (`B09_Estimation_BillOfQuantities` 보조). + +가르는 금은 「표 한 장을 짜는가 / 줄 하나를 만드는가」다. 700줄 제한(CLAUDE.md 4장)에 +걸려 나눴고, 부르는 쪽은 종전대로 조판 모듈에서 가져다 쓴다. + +⚠ **여기 있는 줄 만들기는 하나같이 「못 세우면 금액을 비운다」**로 끝난다 — +0 으로 때우면 총액이 그럴듯해지고 무엇이 빠졌는지 안 보인다. +""" + +from __future__ import annotations + +from decimal import Decimal + +from B09_Estimation.B09_Estimation_BillOfQuantities import ( + SUPPLY_OWNER, + SUPPLY_UNKNOWN, + BillResult, + BillRow, + HandoffMaterial, + HandoffWorkItem, + _BLOCKED_LABELS, + _MasterNode, + _decimal, +) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code + +_ZERO = Decimal(0) + + +def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal: + """그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다.""" + for row in result.rows: + if row.name == item.name and row.unit_price_krw is not None: + return row.unit_price_krw + return _ZERO + + +def _composite_row( + item_no: str, + item: HandoffWorkItem, + unit_prices: UnitPriceBuild, + result: BillResult, +) -> BillRow: + """묶음 줄 — 조각들의 `단가 × 조각수량` 을 더해 **1단위 단가**를 만든다. + + 옹벽처럼 품셈에 그 공종이 없는 것은 **조각을 합친 것이 곧 그 줄의 단가**다 + (PLAN 9-3 「제목 + 상세」 한 쌍). 조각이 하나라도 비면 **금액을 만들지 않는다** — + 절반짜리 단가가 서는 것이 가장 위험하다. + """ + row = BillRow( + item_no=item_no, + level=1, + code=None, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=item.in_bill, + ) + missing_parts: list[str] = [] + money = None + for part in item.composite_parts: + code = str(part.get("code") or "") + amount = _decimal(part.get("quantity"), None) + if not code or amount is None or f"B-{code}" not in unit_prices.book.titles: + missing_parts.append(code or str(part.get("name") or "이름 없음")) + continue + scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount) + money = scaled if money is None else money + scaled + + reasons: list[str] = [] + for pending in item.composite_not_ready: + # 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다. + if isinstance(pending, str): + missing_parts.append(pending) + continue + label = str(pending.get("code") or pending.get("name") or "이름 없음") + why = str(pending.get("reason") or pending.get("note") or "") + missing_parts.append(label) + if why: + reasons.append(f"{label}: {why}") + + if missing_parts or money is None: + detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4]) + row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}" + result.missing.append( + { + "name": row.name, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": ( + f"묶음 조각 미확보 ({len(missing_parts)}건)" + + (f" — {reasons[0]}" if reasons else "") + ), + } + ) + return row + + line = money.scaled(item.quantity) + row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW) + row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + row.material_krw = line.material + row.labor_krw = line.labor + row.expense_krw = line.expense + row.note = f"묶음 {len(item.composite_parts)}조각 합계" + return row + + +def _excluded_row(item: HandoffWorkItem) -> BillRow: + """`in_bill=false` 줄. **수량만 보이고 단가·금액을 안 붙인다.** + + 세 갈래가 섞여 온다 — 갈라 적지 않으면 사용자가 할 일을 못 읽는다. + · **검산용**(보정량계·무대) — 합계 검산에만 쓰는 줄 + · **막힌 줄**(`blocked_kind` 있음) — 입력이나 원단위를 기다림 + · ⚠ **여기서 세지 않는 줄**(`blocked_kind` 없음) — 「다른 표에서 이미 섬」· + 「이 노선엔 없음」. **이것을 할 일 목록에 얹으면 결국 이중계상이 된다.** + """ + return BillRow( + item_no="", + level=1, + code=item.work_item_code, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=False, + note=item.blocked_reason + or item.in_bill_reason + or "합계 검산용 줄 — 금액을 매기지 않습니다.", + ) + + +def _leaf_row( + item_no: str, + node: _MasterNode, + item: HandoffWorkItem, + unit_prices: UnitPriceBuild, + result: BillResult, +) -> BillRow: + """세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다.""" + row = BillRow( + item_no=item_no, + level=node.level, + code=node.code, + name=item.name or node.name, + spec=item.spec, + unit=item.unit, + quantity=item.quantity, + in_bill=item.in_bill, + ) + if item.spec_class_basis: + # 갈래 판정 근거는 **B08 문구를 그대로** 쓴다(두 벌로 짜지 않는다). + row.note = " / ".join(part for part in (row.note, item.spec_class_basis) if part) + if item.application_ratio_pct is not None: + # ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다. + row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량" + + if not item.in_bill: + # 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격). + row.quantity = item.quantity + row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다." + result.excluded.append(row) + return row + + if item.blocked_reason: + # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. + # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 + # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. + row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": row.note, + "blocked_kind": item.blocked_kind, + } + ) + return row + + price_code = f"B-{node.code}" + if item.variant_value and price_code not in unit_prices.book.titles: + # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. + # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. + picked = find_variant_code(node.code, item.variant_value, unit_prices) + if picked is not None: + price_code = picked + row.spec = f"{row.spec} {item.variant_value}".strip() + + if price_code not in unit_prices.book.titles: + # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 + # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). + # 한 층 아래 공종 + **규격 갈래**(`#무근구조물`) 둘 다 후보로 본다. + children = sorted( + code + for code in unit_prices.book.titles + if ( + code.startswith(f"{price_code}-") + and code.count("-") == price_code.count("-") + 1 + and "#" not in code + ) + or code.startswith(f"{price_code}#") + ) + if children: + names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children) + row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}" + reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)" + else: + # ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**. + # 뭉뚱그리면 사용자가 무엇을 기다려야 하는지 못 읽는다(2026-09-08 산마루측구: + # 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리). + gap = unit_prices.component_gaps.get(node.code) + if gap: + row.note = f"성분이 빠져 단가를 못 세웠습니다 — {gap}" + reason = f"성분 미확보 — {gap}" + else: + row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다." + reason = "일위대가 없음" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": reason, + "candidates": ", ".join(children), + } + ) + return row + + missing_basis = unit_prices.basis_missing.get(node.code) + if missing_basis: + # ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배 + # 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.** + row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": "밑수 미확보 — 곱하면 10배·100배 틀림", + } + ) + return row + + covered = unit_prices.partial_ratio.get(node.code) + if covered is not None: + # ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만 + # 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다. + row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)." + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": f"단가 일부만 섬(붙은 몫 {covered}%)", + } + ) + return row + + title = unit_prices.book.title(price_code) + if not title.unit: + # ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다. + # 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**. + # 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다. + row.note = " / ".join( + part + for part in ( + row.note, + f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 " + "보고 곱했습니다. 확인 필요.", + ) + if part + ) + + if title.unit and row.unit and not _same_unit(title.unit, row.unit): + # ⚠⚠ **단위가 다르면 곱하지 않는다** (2026-09-08 실측으로 드러난 자리). + # 돌쌓기(찰)이 B08 에서 **연장 10 m** 로 오는데 품셈 일위대가는 **㎡당**이라, + # 52,938.9원/㎡ × 10 m = 529,389원이 조용히 서 있었다. 면적으로 세면 26.101㎡ × + # 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다. + # 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음 + # 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.** + row.note = ( + f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. " + "곱하면 금액이 틀리므로 비워 둡니다." + ) + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": f"단위 불일치 — 수량 {row.unit} vs 단가 {title.unit}당", + "blocked_kind": "unit_mismatch", + } + ) + return row + + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. + if price_code not in result.used_unit_prices: + result.used_unit_prices.append(price_code) + + unit_money = unit_prices.book.resolve(price_code) + line = unit_money.scaled(item.quantity) + row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) + # 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상. + row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + # 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다. + row.material_krw = line.material + row.labor_krw = line.labor + row.expense_krw = line.expense + return row + + +def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: + """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" + row = BillRow( + item_no="", + level=1, + code=None, + name=material.material_name, + spec=material.spec, + unit=material.unit, + quantity=material.total_amount, + note=material.surcharge_note, + ) + if material.supply_type == SUPPLY_UNKNOWN: + row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다." + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": "공급 구분 미정(unknown)", + } + ) + return row + # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이 + # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 + # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). + if material.supply_type == SUPPLY_OWNER: + row.note = ( + row.note + or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " + "관급자재대(총원가 밖 별도 표기)로 갑니다." + ) + reason = "관급 자재 단가 없음" + else: + row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + reason = "사급 자재 단가 없음(미결 No.18)" + + result.missing.append( + { + "name": material.display_name, + "unit": material.unit, + "quantity": str(material.total_amount), + "reason": reason, + "supply_type": material.supply_type, + } + ) + return row + + +#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다. +_UNIT_ALIASES = { + "㎥": "m3", + "m³": "m3", + "M3": "m3", + "루베": "m3", + "㎡": "m2", + "m²": "m2", + "M2": "m2", + "㎏": "kg", + "KG": "kg", + "톤": "ton", + "TON": "ton", + "t": "ton", + "개소": "개", + "EA": "개", + "ea": "개", + "인": "인", + "인/일": "인", +} + + +def _same_unit(left: str, right: str) -> bool: + """두 단위가 같은가. **표기 차이만 흡수하고, 환산은 하지 않는다** — m 과 ㎡ 는 다르다.""" + + def key(text: str) -> str: + tight = "".join(str(text or "").split()) + return _UNIT_ALIASES.get(tight, tight) + + return key(left) == key(right) diff --git a/B09_Estimation/B09_Estimation_CrewOutput.py b/B09_Estimation/B09_Estimation_CrewOutput.py new file mode 100644 index 00000000..81bc628b --- /dev/null +++ b/B09_Estimation/B09_Estimation_CrewOutput.py @@ -0,0 +1,240 @@ +"""B09 원가계산 — **작업조 + 시공량** 표 읽기 (2026-09-08). + +품셈에는 소요량을 직접 안 주고 **「작업조 몇 인이 하루 몇 ㎡」** 로 주는 표가 있다. + + 구 분 | 단 위 | 수 량 | 시 공 량 (㎡) + | | | 복잡 보통 간단 + 형틀목공 | 인 | 4 | 25 35 40 + 보통인부 | 인 | 1 | + + 1단위당 품 = 인원 ÷ 시공량 (유로폼 12-38-3: 형틀목공 4 ÷ 35 = 0.1143 인/㎡) + +**유형은 우리가 안 고른다.** 품셈 12-38-3 [유형] 표가 「보통 : 측구, 수로, 옹벽 …」로 +정해 두었으므로 **갈래(`#복잡`·`#보통`·`#간단`)로 세워 두고 고르는 것은 B08**에 맡긴다 +(철근 12-3 [주]① 과 같은 모양). + +⚠ **뭉쳐 온 칸을 짝지어 읽는다.** 이름·단위·인원이 한 칸에 붙어 온다 +(`형틀목공 보통인부` | `인 인` | `4 1`). **개수가 안 맞으면 그 표를 통째로 버린다** — +자리를 밀어 읽으면 다른 직종의 품이 붙는다(오늘 여러 번 겪은 자리). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +_RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$") +#: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다. +_OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량") + + +@dataclass(frozen=True) +class CrewMember: + """작업조 한 사람(또는 한 대).""" + + code: str + name: str + spec: str + kind: str + count: Decimal + + +@dataclass(frozen=True) +class CrewTable: + """작업조 표 한 장. `outputs` 는 유형별 시공량 — 갈래가 하나면 이름이 빈 문자열.""" + + members: tuple[CrewMember, ...] + outputs: tuple[tuple[str, Decimal], ...] + + def amount_of(self, member: CrewMember, output: Decimal) -> Decimal: + """1단위당 품 = 인원 ÷ 시공량.""" + return member.count / output + + +def _normalize(text: str) -> str: + return " ".join(str(text).split()) + + +def _is_output_header(cell: str) -> bool: + tight = "".join(str(cell).split()) + return any(word in tight for word in _OUTPUT_WORDS) + + +def _numbers_in(cell: str) -> list[Decimal]: + """한 칸에 뭉쳐 온 수들 — 「4 1」 → [4, 1].""" + found: list[Decimal] = [] + for token in _normalize(cell).split(" "): + if _RE_NUMBER.match(token): + found.append(Decimal(token.replace(",", ""))) + return found + + +def output_columns(table: dict[str, Any]) -> list[int]: + """시공량 열의 번호. 없으면 빈 목록 — 작업조 표가 아니다.""" + headers = table.get("condition_note") or [] + return [index for index, cell in enumerate(headers) if _is_output_header(cell)] + + +def parse_crew_table( + table: dict[str, Any], + catalog: ResourceCatalog, +) -> CrewTable | str: + """작업조 표를 읽는다. 못 읽으면 **사유 문자열**을 돌려준다(빈 표를 만들지 않는다).""" + if not output_columns(table): + return "시공량 열이 없습니다" + + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return "빈 표입니다" + + members: list[CrewMember] = [] + outputs: list[Decimal] = [] + labels: list[str] = [] + + for row in rows: + if not row or not row[0]: + continue + head = row[0] + # 첫 칸에 자원 이름이 뭉쳐 올 수 있다 — 「형틀목공 보통인부」. + # ⚠ 이름 안에 공백이 든 표가 있다 — 「조 경 공」·「비 계 공」. 공백을 구분자로만 + # 보면 그런 줄이 통째로 안 풀린다. **한 이름으로 먼저 시도**하고, 안 되면 쪼갠다. + tight = "".join(_normalize(head).split(" ")) + single = _resolve(catalog, tight) + if single is not None: + names, resolved = [tight], [single] + else: + names = [part for part in _normalize(head).split(" ") if part] + resolved = [_resolve(catalog, name) for name in names] + if not all(resolved): + # 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」. + if not _numbers_in(" ".join(row)): + if not members: + labels = [_normalize(cell) for cell in row if _normalize(cell)] + continue + # ⚠ **수가 있는데 이름을 못 푼 줄은 작업조의 한 몫**이다 — 조용히 건너뛰면 + # 그 몫이 빠진 채 단가가 선다(2026-09-08: 평떼 시비에서 「트럭 2.5ton 1대」가 + # 빠지고 노무만으로 28.6원/㎡ 이 섰다). 표를 통째로 버린다. + return f"작업조 줄 「{_normalize(head)[:20]}」을 못 풀었습니다" + + counts = _counts_of(row, len(resolved)) + if counts is None: + return f"인원 수가 이름 {len(resolved)} 개와 안 맞습니다" + for entry, count in zip(resolved, counts): + members.append( + CrewMember( + code=entry.code, + name=entry.name, + spec=entry.spec, + kind=entry.kind, + count=count, + ) + ) + # 시공량은 보통 **첫 작업조 줄**에 붙어 온다. + if not outputs: + outputs = _outputs_of(row, counts) + + if not members: + return "작업조 줄을 못 찾았습니다" + if not outputs: + return "시공량 값을 못 찾았습니다" + if labels and len(labels) != len(outputs): + # 라벨과 값의 개수가 다르면 **어느 유형인지 단정할 수 없다** — 읽지 않는다. + return f"유형 {len(labels)} 개와 시공량 {len(outputs)} 개가 안 맞습니다" + + named = tuple((labels[index] if labels else "", value) for index, value in enumerate(outputs)) + return CrewTable(members=tuple(members), outputs=named) + + +def _resolve(catalog: ResourceCatalog, name_cell: str): + name, spec = split_name_and_spec(name_cell) + entry = catalog.resolve(name, spec) + if entry is not None: + return entry + if spec: + return None + found = catalog.by_name(name) + return found[0] if len(found) == 1 else None + + +def _counts_of(row: list[str], wanted: int) -> list[Decimal] | None: + """이름 개수와 같은 만큼의 인원 수를 가진 칸을 찾는다. 없으면 `None`.""" + for cell in row[1:]: + numbers = _numbers_in(cell) + if len(numbers) == wanted: + return numbers + return None + + +def _outputs_of(row: list[str], counts: list[Decimal]) -> list[Decimal]: + """인원 칸 **뒤**에 오는 수들이 시공량이다.""" + seen_counts = False + values: list[Decimal] = [] + for cell in row[1:]: + numbers = _numbers_in(cell) + if not numbers: + continue + if not seen_counts and numbers == counts: + seen_counts = True + continue + if seen_counts: + values.extend(numbers) + return values + + +def match_crew_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, + unit: str, +) -> bool: + """작업조 표를 자원 축 줄로 바꾼다. 그런 표가 아니면 `False`.""" + if not output_columns(table): + return False + + work_item_code = node.get("work_item_code", "") + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + parsed = parse_crew_table(table, catalog) + if isinstance(parsed, str): + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=_normalize(" | ".join(str(c) for c in (table.get("condition_note") or []))), + reason=f"작업조 표를 못 읽었습니다 — {parsed}", + ) + ) + return True # 다른 길로 보내지 않는다 — 행-자원으로 읽으면 인원을 소요량으로 오해한다 + + for index, (label, output) in enumerate(parsed.outputs): + if output <= 0: + continue + for member in parsed.members: + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=member.kind, + resource_code=member.code, + resource_name=member.name, + resource_spec=member.spec, + amount=parsed.amount_of(member, output), + amount_unit=unit, + raw_row_index=index, + variant=label, + ) + ) + return True diff --git a/B09_Estimation/B09_Estimation_Engine_Cost.py b/B09_Estimation/B09_Estimation_Engine_Cost.py new file mode 100644 index 00000000..2f749d3a --- /dev/null +++ b/B09_Estimation/B09_Estimation_Engine_Cost.py @@ -0,0 +1,534 @@ +"""B09 원가계산 — ⑤ 공사원가계산서 엔진. + +순공사비(직접재료비·직접노무비·직접경비)를 받아 법정경비·일반관리비·이윤·부가세를 얹어 +**공사원가계산서 한 장**을 만든다. 수량·단가와 무관하게 홀로 도는 계산이다 (PLAN 9-5). +법정경비 계산은 `B09_Estimation_Statutory` 로 나눠 두었다 (700줄 제한). + +지켜야 할 것 (PLAN 8-9 「엔진이 지켜야 할 것 7가지」 — 실무 원가계산서 재현으로 확인) + 1. **모든 줄은 원 단위 버림**(ROUNDDOWN). 반올림이 아니다. + 2. **밑수가 항목마다 갈린다** — 직노 / 직노+간노 / 건강보험료 / 재료비+직노+관급항 / … + 3. **안전관리비 = A·B 두 값을 다 내고 작은 쪽.** A 가 항상 작지 않다. + 4. **이윤 밑수 = (순공사원가 + 일반관리비) − 재료비.** + 5. **이윤 수동 조정액** — 설계자가 명시로 넣을 때만. 자동 역산 금지 (★법대로 8-10). + 6. **관급자재대 = ROUNDUP(원자재대(+조달수수료), 천원)** — 총원가 밖 별도 표기. + 7. 요율은 전부 데이터에서 읽는다. **코드에 요율 숫자가 없다.** +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal + +from B09_Estimation.B09_Estimation_Rates import ( + RateDataset, + flat_rate, + load_rate_dataset, + load_rate_dataset_from_path, + rate_percent, + select_bracket, +) +from B09_Estimation.B09_Estimation_Statutory import ( + ExpenseContext, + available_items, + statutory_expenses, +) + +_ZERO = Decimal(0) +_HUNDRED = Decimal(100) + +#: 기본으로 켜는 비목 = **그 해 요율 데이터에 있는 것 전부**. +#: 사용자 확정(2026-09-07, PLAN 8-14): 실무 서류에 없다고 빼지 않는다. +DEFAULT_ITEMS = "ALL_AVAILABLE" + + +def floor_won(value: Decimal) -> Decimal: + """원 단위 버림 — 원가계산서 모든 줄의 기본 처리 (PLAN 8-9 규칙 1).""" + return value.quantize(Decimal(1), rounding=ROUND_FLOOR) + + +def ceil_thousand(value: Decimal) -> Decimal: + """천원 올림 — 관급자재대 표기 (PLAN 8-9 규칙 7).""" + return (value / 1000).quantize(Decimal(1), rounding=ROUND_CEILING) * 1000 + + +@dataclass +class CostInput: + """원가계산 입력. 금액은 전부 원 단위 `Decimal`.""" + + direct_material_krw: Decimal + direct_labor_krw: Decimal + direct_expense_krw: Decimal + indirect_material_krw: Decimal = _ZERO + + #: 구간 판정용 공종·기간. `work_type` 값은 요율 데이터의 표기를 그대로 쓴다. + work_type_indirect_labor: str = "civil" + work_type_safety: str = "civil" + duration_days: int = 183 + pension_year: int = 2026 + + #: 관급자재 — **순자재대**와 조달수수료를 나눠 받는다(순환 정의 방지, 원가계산_체계 §1). + #: ⚠ `owner_supplied_material_krw` 는 **수수료를 뺀 순자재대**다. 실무 서류의 + #: 「관급자재대」는 이미 `순자재대 + 수수료` 를 천원 올림한 값이므로 그대로 넣으면 안 된다 + #: (2026-09-07 실측 정정 — 울진 순자재대 69,474,220 + 수수료 375,160 = 69,849,380 → + #: 천원 올림 69,850,000). 안전관리비 관급항도 **순자재대만** 쓴다. + owner_supplied_material_krw: Decimal = _ZERO + procurement_fee_krw: Decimal = _ZERO + include_fee_in_owner_material_total: bool = True + + #: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액. + owner_supplied_for_safety_krw: Decimal | None = None + #: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준). + owner_supplied_includes_vat: bool = True + #: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다. + #: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조). + #: 근거 — 국가계약법 시행령 제7조 1호 「공사계약의 경우에는 관급자재로 공급될 + #: 부분의 가격을 제외한 금액」. 우리 계산의 그 값은 **총원가**(부가세 전, 관급 밖). + estimated_price_krw: Decimal | None = None + + #: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다. + profit_adjustment_krw: Decimal = _ZERO + + #: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히. + #: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」). + #: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐. + waste_disposal_krw: Decimal = _ZERO + + #: 환경보전비 공종 (`rate_environment.all_work_types` 의 값). + #: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정. + #: 잠정 = 도로(0.9 %). 요율 데이터가 `pending` 을 달고 있어 결과 줄에 경고가 붙는다. + environment_work_type: str = "civil_road" + #: 건설기계대여대금 지급보증 공종. + equipment_guarantee_work_type: str = "civil_general" + #: 하도급대금 지급보증 — 30억 이상 구간이 공종으로 갈린다(토목·산업설비 / 건축). + subcontract_guarantee_variant: str = "integrated_civil_or_industrial" + + #: 켤 비목. 기본은 「그 해 요율 데이터에 있는 것 전부」. + enabled_items: tuple[str, ...] | str = DEFAULT_ITEMS + + #: 요율 데이터 파일명. **연도를 갈아끼우는 자리.** + rate_file_name: str = "rates_2026.json" + #: 매니페스트 밖 요율 파일(옛 연도 재현 검산 전용). 주면 이쪽이 우선. + rate_file_path: str | None = None + + +@dataclass +class CostLine: + """원가계산서 한 줄 — 화면이 「비목·금액·요율·산출근거」를 다 보이므로 넷을 다 든다.""" + + key: str + name: str + base_label: str + base_amount_krw: Decimal + rate_percent: Decimal | None + flat_amount_krw: Decimal + amount_krw: Decimal + note: str = "" + + @property + def formula_text(self) -> str: + """화면 `산출근거` 칸 문구 — 줄마다 **제 산식**을 적는다. + + 실무 원문은 안전관리비 A 식을 B 줄에 복사해 둔 오류가 있었다(PLAN 8-13). + """ + if self.rate_percent is None or self.key == "safety_management_cost": + # 채택 요약 줄은 요율을 다시 붙이지 않는다 — 산식은 A·B 줄에 이미 있다. + return self.base_label + # 밑수가 합·차로 이루어졌으면 괄호를 씌운다. + # 안 씌우면 「(순공사원가+일반관리비) − 재료비 × 15%」처럼 곱하는 대상이 뒤바뀌어 읽힌다. + base = self.base_label + if any(mark in base for mark in ("+", "−", "×")): + base = f"({base})" + text = f"{base} × {self.rate_percent}%" + if self.flat_amount_krw: + text += f" + {self.flat_amount_krw:,.0f}" + if self.key == "safety_management_cost_b": + text = f"[{text}] × 1.2" + return text + + +@dataclass +class CostResult: + lines: list[CostLine] = field(default_factory=list) + totals: dict[str, Decimal] = field(default_factory=dict) + rate_version: dict[str, str] = field(default_factory=dict) + notes: list[str] = field(default_factory=list) + + def line(self, key: str) -> CostLine: + for item in self.lines: + if item.key == key: + return item + raise KeyError(f"원가계산서에 없는 줄입니다: {key}") + + def amount(self, key: str) -> Decimal: + return self.line(key).amount_krw + + def has(self, key: str) -> bool: + return any(item.key == key for item in self.lines) + + +def _load_dataset(data: CostInput) -> RateDataset: + if data.rate_file_path: + return load_rate_dataset_from_path(data.rate_file_path) + return load_rate_dataset(data.rate_file_name) + + +def _emitter(result: CostResult): + """줄 하나를 계산해 결과에 담고 금액을 돌려주는 함수를 만든다.""" + + def emit( + *, + key: str, + name: str, + base_label: str, + base: Decimal, + percent: Decimal | None = None, + flat: Decimal = _ZERO, + raw: Decimal | None = None, + amount: Decimal | None = None, + note: str = "", + ) -> Decimal: + if amount is None: + computed = raw if raw is not None else base * (percent or _ZERO) / _HUNDRED + flat + amount = floor_won(computed) + result.lines.append( + CostLine( + key=key, + name=name, + base_label=base_label, + base_amount_krw=base, + rate_percent=percent, + flat_amount_krw=flat, + amount_krw=amount, + note=note, + ) + ) + return amount + + return emit + + +#: 규모 구간 수렴 반복 상한. 2~3회면 고정된다. +_SCALE_MAX_PASSES = 5 + + +def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple: + """이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문. + + 규모(추정가격)로 갈리는 요율만 모은다. 지문이 같으면 더 돌 필요가 없다. + """ + parts: list[str] = [] + for variable, bracket_field, key in ( + ("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"), + ("rate_profit", "estimated_price_bracket", "brackets"), + ("rate_goyong", "estimated_amount_bracket", "brackets"), + ("rate_subcontract_payment_guarantee", "estimated_price_bracket", "brackets"), + ): + if variable not in dataset.variables: + continue + rows = dataset.variable(variable)[key] + try: + row = select_bracket( + rows, + amount_field=bracket_field, + amount=amount, + residual_label="below_official_threshold", + label=variable, + ) + except Exception: # noqa: BLE001 - 구간 밖이면 지문에서 뺀다 + parts.append(f"{variable}:none") + continue + parts.append(f"{variable}:{row.get(bracket_field)}") + + # 적용 하한(추정금액 1억 이상 등)도 구간과 같은 축이다. + for variable in ("rate_environment", "rate_retirement_mutual_aid"): + if variable not in dataset.variables: + continue + minimum = dataset.variable(variable).get("minimum_estimated_amount_krw") + if minimum is not None: + parts.append(f"{variable}:met={amount >= Decimal(str(minimum))}") + return tuple(parts) + + +def calculate_cost(data: CostInput) -> CostResult: + """공사원가계산서 한 장을 계산한다. + + **규모 구간은 「추정가격」으로 판정한다** — 국가계약법 시행령 제7조 1호: + 「공사계약의 경우에는 **관급자재로 공급될 부분의 가격을 제외한 금액**」. + 우리 계산에서 그 값은 **총원가**다(부가세 전, 관급자재대는 애초에 총원가 밖). + + 그런데 총원가는 계산 **결과**라 구간 판정에 그대로 쓰면 순환이 된다. 그래서 + 직접공사비를 씨앗으로 한 번 돌린 뒤 **나온 총원가로 구간을 다시 판정**해 + 구간이 고정될 때까지 되풀이한다(최대 `_SCALE_MAX_PASSES` 회). 설계자가 + `estimated_price_krw` 를 명시하면 반복 없이 그 값으로 한 번만 판정한다. + """ + dataset = _load_dataset(data) + + if data.estimated_price_krw is not None: + return _calculate_with_scale(data, dataset, data.estimated_price_krw, []) + + scale = ( + data.direct_material_krw + + data.indirect_material_krw + + data.direct_labor_krw + + data.direct_expense_krw + ) + seen_signatures: list[tuple] = [] + tried_amounts: list[Decimal] = [] + + for _ in range(_SCALE_MAX_PASSES): + signature = _scale_signature(dataset, scale) + if signature in seen_signatures: + # 구간이 진동한다 — 보수적으로 **높은 쪽**을 잡고 그 사실을 남긴다. + highest = max([*tried_amounts, scale]) + return _calculate_with_scale( + data, dataset, highest, ["규모 구간 진동 — 높은 쪽 구간 채택"] + ) + seen_signatures.append(signature) + tried_amounts.append(scale) + + trial = _calculate_with_scale(data, dataset, scale, []) + estimated_price = trial.totals["total_cost"] + if _scale_signature(dataset, estimated_price) == signature: + return trial + scale = estimated_price + + return _calculate_with_scale( + data, dataset, scale, [f"규모 구간이 {_SCALE_MAX_PASSES}회 안에 안 굳음 — 마지막 값 채택"] + ) + + +def _calculate_with_scale( + data: CostInput, + dataset: RateDataset, + scale: Decimal, + notes: list[str], +) -> CostResult: + """규모 기준액을 못 박고 한 번 계산한다.""" + result = CostResult(rate_version=dataset.version_stamp, notes=list(notes)) + emit = _emitter(result) + + if data.enabled_items == DEFAULT_ITEMS: + data = replace(data, enabled_items=available_items(dataset)) + + material_cost = data.direct_material_krw + data.indirect_material_krw + emit( + key="material_cost", + name="재료비", + base_label="직접재료비+간접재료비", + base=material_cost, + amount=material_cost, + ) + + direct_construction_cost = material_cost + data.direct_labor_krw + data.direct_expense_krw + + indirect_row = select_bracket( + dataset.variable("rate_indirect_labor")["brackets"], + amount_field="direct_cost_bracket", + amount=direct_construction_cost, + duration_days=data.duration_days, + equals={"work_type": data.work_type_indirect_labor}, + label="간접노무비", + ) + indirect_labor = emit( + key="indirect_labor_cost", + name="간접노무비", + base_label="직접노무비", + base=data.direct_labor_krw, + percent=rate_percent(indirect_row, label="간접노무비"), + ) + total_labor_cost = data.direct_labor_krw + indirect_labor + emit( + key="labor_cost", + name="노무비", + base_label="직접노무비+간접노무비", + base=total_labor_cost, + amount=total_labor_cost, + ) + + ctx = ExpenseContext( + material_cost=material_cost, + direct_labor_cost=data.direct_labor_krw, + total_labor_cost=total_labor_cost, + direct_construction_cost=direct_construction_cost, + scale_reference=scale, + ) + statutory = statutory_expenses(dataset, data, ctx, result, emit) + + expense_total = data.direct_expense_krw + statutory + emit( + key="expense", + name="경비", + base_label="직접경비(산출경비)+법정경비", + base=expense_total, + amount=expense_total, + ) + + net_construction_cost = material_cost + total_labor_cost + expense_total + emit( + key="net_construction_cost", + name="순공사원가", + base_label="재료비+노무비+경비", + base=net_construction_cost, + amount=net_construction_cost, + ) + + overhead_row = select_bracket( + dataset.variable("rate_overhead")["civil_landscape_industrial"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + label="일반관리비", + ) + overhead = emit( + key="general_overhead", + name="일반관리비", + base_label="순공사원가", + base=net_construction_cost, + percent=rate_percent(overhead_row, label="일반관리비"), + ) + + profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead) + + total_cost = net_construction_cost + overhead + profit + emit( + key="total_cost", + name="총원가", + base_label="순공사원가+일반관리비+이윤", + base=total_cost, + amount=total_cost, + ) + vat = emit( + key="vat", + name="부가가치세", + base_label="총원가", + base=total_cost, + percent=flat_rate(dataset, "rate_vat"), + ) + contract_amount = total_cost + vat + emit( + key="contract_amount", + name="도급공사비", + base_label="총원가+부가가치세", + base=contract_amount, + amount=contract_amount, + ) + + owner_total = _owner_supplied_line(data, emit) + waste = _waste_line(data, emit) + + grand_total = contract_amount + owner_total + waste + emit( + key="grand_total", + name="총공사비", + base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""), + base=grand_total, + amount=grand_total, + ) + + result.totals = { + "material_cost": material_cost, + "labor_cost": total_labor_cost, + "expense": expense_total, + "direct_construction_cost": direct_construction_cost, + "net_construction_cost": net_construction_cost, + "general_overhead": overhead, + "profit": profit, + "total_cost": total_cost, + "vat": vat, + "contract_amount": contract_amount, + "owner_supplied_material_total": owner_total, + "waste_disposal": waste, + "grand_total": grand_total, + } + return result + + +def _profit_lines( + dataset: RateDataset, + data: CostInput, + emit, + ctx: ExpenseContext, + net_construction_cost: Decimal, + overhead: Decimal, +) -> Decimal: + """이윤 — 조정 전 / 조정액 / 조정 후 세 줄. 조정은 **명시 입력일 때만**.""" + profit_row = select_bracket( + dataset.variable("rate_profit")["brackets"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + label="이윤", + ) + percent = rate_percent(profit_row, label="이윤") + profit_base = net_construction_cost + overhead - ctx.material_cost + before = emit( + key="profit_before_adjustment", + name="이윤(조정 전)", + base_label="(순공사원가+일반관리비) − 재료비", + base=profit_base, + percent=percent, + ) + if data.profit_adjustment_krw: + emit( + key="profit_adjustment", + name="이윤 조정액", + base_label="설계자 명시 입력", + base=_ZERO, + amount=-data.profit_adjustment_krw, + note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)", + ) + profit = before - data.profit_adjustment_krw + emit( + key="profit", + name="이윤", + base_label="조정 전 이윤 − 조정액", + base=profit_base, + amount=profit, + ) + return profit + + +def _owner_supplied_line(data: CostInput, emit) -> Decimal: + """관급자재대 — 총원가 밖 별도 표기, 천원 올림.""" + if not data.owner_supplied_material_krw: + return _ZERO + raw = data.owner_supplied_material_krw + if data.include_fee_in_owner_material_total: + raw = raw + data.procurement_fee_krw + return emit( + key="owner_supplied_material_total", + name="관급자재대", + base_label=( + "순자재대+조달수수료 (천원 올림)" + if data.include_fee_in_owner_material_total + else "순자재대 (천원 올림)" + ), + base=raw, + amount=ceil_thousand(raw), + note="총원가 밖 별도 표기", + ) + + +def _waste_line(data: CostInput, emit) -> Decimal: + """폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다.""" + if not data.waste_disposal_krw: + return _ZERO + return emit( + key="waste_disposal", + name="폐기물처리비", + base_label="설계자 입력(실비)", + base=data.waste_disposal_krw, + amount=floor_won(data.waste_disposal_krw), + note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)", + ) + + +def proposed_profit_adjustment(result: CostResult, target_contract_amount: Decimal) -> Decimal: + """목표 도급공사비를 맞추려면 이윤을 얼마 깎아야 하는지 **보여만 준다**. + + ★ 법대로(8-10) — 프로그램이 스스로 적용하지 않는다. 설계자가 이 값을 보고 + `CostInput.profit_adjustment_krw` 에 명시로 넣어야 반영된다. + """ + gap = result.totals["contract_amount"] - target_contract_amount + if gap <= 0: + return _ZERO + # 이윤 1원을 깎으면 총원가 1원 + 부가세 0.1원이 줄어든다. + return floor_won(gap / Decimal("1.1")) diff --git a/B09_Estimation/B09_Estimation_Guards.py b/B09_Estimation/B09_Estimation_Guards.py new file mode 100644 index 00000000..0bf0fdc7 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Guards.py @@ -0,0 +1,297 @@ +"""B09 원가계산 — 이중계상 감시 (거울 테스트 4종). + +**왜 있는가** — 수량(B08)과 원가(B09)의 담당이 갈렸다가 합쳐졌다가 다시 갈리는 동안, +「할증을 두 번 붙인다·20 m 운반을 또 센다·콘크리트를 두 번 쪼갠다」 세 자리가 반복해서 +위험 항목으로 올라왔다(PLAN 8-7 금지 규칙). 주석은 읽히지 않으므로 **수치로 깨지는 +검사**를 두어, 규칙을 어기면 계산이 멈추게 한다. + +네 규칙 (㉠㉡㉢ 원문 = PLAN 8-7 · ㉣ = PLAN 9-6, 2026-09-07 구현 중 발견) + ㉠ **할증은 자재총괄에서 딱 한 번.** 일위대가 재료비 구성은 **할증 전** 값을 쓴다 + (품셈 1-3-1 「할증 중복 적용 금지」). + ㉡ **소운반 20 m 이내(`free_haul`)는 내역 줄에 단가를 붙이지 않는다.** 품에 이미 + 포함돼 있고, 품셈에 20 m 이내 운반 품목 자체가 없다(1-2-7 · 인력운반 10-6). + ㉢ **콘크리트·모르터는 한 번만 쪼갠다.** 원단위표는 「㎥」까지 내고, 시멘트·모래 + 분해는 일위대가에서 한 번만 한다. + ㉣ **작업효율 `E` 를 두 곳에 넣지 않는다.** 품셈은 작업량 산정에 넣는다 + (`Q = n·q·f·E`, 8-1-4). 시간당 사용료는 1일 8시간으로 나눈다(8-1-6). + 실무 관측이 사용료 쪽에 미리 넣어 두어, 그 값을 보고 나눗수를 고치는 사고가 난다. +""" + +from __future__ import annotations + +from decimal import Decimal + +_TOLERANCE = Decimal("0.5") + + +class DoubleCountError(AssertionError): + """이중계상이 감지된 경우. 값을 고치지 않고 여기서 멈춘다.""" + + +def check_surcharge_once( + *, + material_summary_total: Decimal, + unit_price_material_total: Decimal, + surcharge_rate_percent: Decimal, + label: str = "자재", +) -> None: + """㉠ 할증이 두 번 붙지 않았는가. + + `material_summary_total` = 자재총괄의 **할증 포함** 합계. + `unit_price_material_total` = 일위대가 재료비 구성의 **할증 전** 합계. + 둘의 비가 (1 + 할증률) 을 **넘으면** 어딘가에서 할증을 또 붙인 것이다. + """ + if unit_price_material_total <= 0: + return + expected = unit_price_material_total * (Decimal(1) + surcharge_rate_percent / Decimal(100)) + if material_summary_total > expected + _TOLERANCE: + raise DoubleCountError( + f"{label}: 할증이 두 번 붙었습니다 — 자재총괄 {material_summary_total:,.2f} > " + f"할증 전 {unit_price_material_total:,.2f} × (1+{surcharge_rate_percent}%) " + f"= {expected:,.2f}. 할증은 자재총괄에서 한 번만 (PLAN 8-7 ㉠)." + ) + + +def check_free_haul_not_priced( + *, + haul_rows: list[dict], + equipment_field: str = "equipment", + unit_price_field: str = "unit_price_krw", + free_haul_equipment: str = "free_haul", +) -> None: + """㉡ 무대(20 m 이내) 줄에 단가가 붙지 않았는가. + + 줄 자체는 실무 서식대로 남긴다(STmate `W00005 무대처리` 는 금액 0 으로 실재). + 금지되는 것은 **단가를 붙이는 것**이다. + """ + for row in haul_rows: + if row.get(equipment_field) != free_haul_equipment: + continue + price = Decimal(str(row.get(unit_price_field) or 0)) + if price != 0: + raise DoubleCountError( + f"무대(20 m 이내) 줄에 단가 {price:,.0f} 원이 붙었습니다 — " + "소운반 20 m 이내는 품에 포함이라 별도 계상하지 않습니다 (PLAN 8-7 ㉡)." + ) + + +def check_haul_volume_within_cut( + *, + haul_volume_total_m3: Decimal, + total_cut_volume_m3: Decimal, +) -> None: + """㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가. + + 무대 줄을 잘못 이중으로 세면 합이 절취량을 넘는다. + """ + if haul_volume_total_m3 > total_cut_volume_m3 + _TOLERANCE: + raise DoubleCountError( + f"운반토량 합 {haul_volume_total_m3:,.2f} ㎥ 가 총 절취량 " + f"{total_cut_volume_m3:,.2f} ㎥ 를 넘습니다 — 같은 토량을 두 번 셌습니다 " + "(PLAN 8-7 ㉡)." + ) + + +#: ⚠ **아직 부르는 자리가 없다** (2026-09-08 메인 창 교차검토에서 확인). +#: 배합 분해(콘크리트 → 시멘트·모래·자갈)가 우리 일위대가에 아직 안 서 있어 +#: 넘길 값이 없다. **「있다」와 「돈다」는 다르다** — 조건이 생기는 곳을 아래에 적어 둔다. +#: +#: **부를 자리** — `B09_Estimation_UnitPrice.build_unit_prices()` 에서 콘크리트 계열 +#: (`FP-12-01*`)에 시멘트·모래·자갈 줄이 붙기 시작하면, 그 공종의 시멘트 총량과 +#: 콘크리트 체적을 넘겨 부를 것. 지금 그 표는 「㎥」까지만 내고 분해가 없다. +def check_mix_decomposed_once( + *, + cement_total_kg: Decimal, + concrete_volume_m3: Decimal, + cement_per_m3_kg: Decimal, +) -> None: + """㉢ 콘크리트를 두 번 쪼개지 않았는가. + + 시멘트 총량이 `콘크리트 체적 × 배합비` 를 넘으면, 원단위표가 이미 분해한 값을 + 일위대가가 또 분해한 것이다. + """ + if concrete_volume_m3 <= 0: + return + expected = concrete_volume_m3 * cement_per_m3_kg + if cement_total_kg > expected + _TOLERANCE: + raise DoubleCountError( + f"시멘트 {cement_total_kg:,.2f} kg 가 콘크리트 {concrete_volume_m3:,.2f} ㎥ × " + f"{cement_per_m3_kg} kg/㎥ = {expected:,.2f} kg 를 넘습니다 — 배합을 두 번 " + "쪼갰습니다. 원단위표는 「콘크리트 ㎥」까지만 냅니다 (PLAN 8-7 ㉢)." + ) + + +def reject_efficiency_in_hourly_rate(factor: Decimal | None, *, where: str) -> None: + """㉣ 작업효율 `E`(실작업시간율)가 **시간당 사용료 쪽**에 들어오면 멈춘다. + + 품셈은 작업효율을 **작업량 산정**에 넣는다 — `Q = n·q·f·E` (8-1-4). + 시간당 사용료는 **1일 8시간**으로 나눈다 (8-1-6 「관리비는 1일 8시간 초과해도 + 8시간으로 계산」). **양쪽에 다 넣으면 같은 효율이 두 번 곱해진다.** + + 실무 관측(STC 2024)은 효율을 사용료 쪽에 미리 넣어 굴착기 0.7㎥ 노무가 + 55,700 원/hr(일당 ÷ 약 5.09시간)로 나온다. 그 값을 보고 「우리가 틀렸다」며 + 나눗수를 고치는 것이 정확히 이 자리에서 일어난다. + """ + if factor is None: + return + if Decimal(0) < Decimal(str(factor)) < Decimal(1): + raise DoubleCountError( + f"{where}: 작업효율({factor})이 시간당 사용료 계산에 들어왔습니다 — " + "효율은 작업량 산정(Q = n·q·f·E)에만 넣습니다. 양쪽에 넣으면 두 번 곱해집니다 " + "(PLAN 9-6 ㉣)." + ) + + +def check_operator_hours_basis( + *, + labor_per_hour: Decimal, + daily_wage: Decimal, + person_days: Decimal = Decimal(1), + hours_per_day: int = 8, +) -> None: + """㉣ 보조 — 조종원 노임 나눗수가 8시간인가. + + 나눗수를 몰래 줄이는 것이 곧 효율을 사용료에 넣는 것과 같다. + """ + expected = daily_wage * person_days / Decimal(hours_per_day) + if abs(labor_per_hour - expected) > _TOLERANCE: + raise DoubleCountError( + f"조종원 시간당 노무비 {labor_per_hour:,.2f} 가 " + f"{daily_wage:,.0f} × {person_days} ÷ {hours_per_day}h = {expected:,.2f} 와 다릅니다 — " + "나눗수를 줄이면 작업효율을 사용료에 넣은 것이 됩니다 (PLAN 9-6 ㉣)." + ) + + +def check_column_sums( + *, + rows: list[dict], + totals: dict[str, Decimal], + columns: tuple[str, ...] = ("material", "labor", "expense", "total"), + label: str = "본표", +) -> None: + """㉤ **열 방향** 검사 — 표시된 합계가 상세 줄의 열별 합과 같은가. + + `TC = NC + GC + JC` 는 **행 방향** 검사라 「같은 성분을 두 층에서 세는」 어긋남을 + 못 잡는다(행마다는 다 맞는데 열 합만 갈리는 모양). 그래서 방향을 하나 더 둔다. + + 예 — 기계 줄 안에 든 조종원 노무가 별도 노무 줄로도 서면 노무 열만 부풀고 + 행 검사는 전부 통과한다. + """ + for column in columns: + column_sum = sum((Decimal(str(row[column])) for row in rows), Decimal(0)) + shown = Decimal(str(totals[column])) + if abs(column_sum - shown) > _TOLERANCE: + raise DoubleCountError( + f"{label}: `{column}` 열 합계가 어긋납니다 — 줄 합 {column_sum:,.2f} vs " + f"표시 {shown:,.2f}. 같은 성분을 두 층에서 셌을 수 있습니다 " + "(행 방향 `TC=NC+GC+JC` 검사로는 안 잡힘)." + ) + + +def check_excluded_rows_not_priced( + *, + rows: list[dict], + amount_field: str = "amount_krw", + unit_price_field: str = "unit_price_krw", + label: str = "내역서", +) -> None: + """㉡ 확장 — `in_bill=false` 줄(보정량계·무대 등)에 금액이 붙지 않았는가. + + B08 은 검산용 줄도 **수량을 그대로 실어 보낸다**(계약 확정). 수량이 있으니 + 조판이 무심코 단가를 붙이면 같은 것을 두 번 세게 된다 — 합계 줄과 그 아래 + 상세 줄이 함께 더해지는 모양이라 **행 검사로는 안 잡힌다**. + """ + for row in rows: + for field_name in (amount_field, unit_price_field): + value = row.get(field_name) + if value in (None, ""): + continue + if Decimal(str(value)) != 0: + raise DoubleCountError( + f"{label}: 합계·검산용 줄(`in_bill=false`)에 {field_name} " + f"{Decimal(str(value)):,.0f} 이 붙었습니다 — 그 줄은 수량만 보이고 " + "금액을 매기지 않습니다 (PLAN 8-7 ㉡ 와 같은 성격)." + ) + + +#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다. +#: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를 +#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**. +OVERHEAD_TIER_WITH_PIPE = "with_pipe" +OVERHEAD_TIER_WITHOUT_PIPE = "without_pipe" + +#: 물빼기 파이프를 가리키는 자재 이름들. **정확 일치**로만 본다 — 넓게 잡으면 +#: 「물구멍 마감재」 같은 정상 자재까지 지운다. +DRAIN_PIPE_NAMES = ("물빼기파이프", "물빼기 파이프", "물구멍", "배수공") + + +#: ⚠ **아직 부르는 자리가 없다** — 제잡비를 늘 **아랫단**(파이프 미설치)으로만 붙여 +#: 조건이 안 온다. 값이 틀린 것은 아니고, **윗단을 쓰게 되는 날 막을 것이 없는** 상태다. +#: +#: **부를 자리** — `B09_Estimation_BillOfQuantities.build_bill()` 의 자재 검사 옆. +#: 제잡비 윗단을 고르는 설계 조건(물빼기 파이프 설치 여부)이 인계에 실리면, +#: 그 조건과 자재 이름 목록을 넘겨 부를 것. 지금은 그 칸 자체가 없다. +def check_drain_pipe_not_double_counted( + *, + overhead_tier: str, + material_names: list[str], + label: str = "돌쌓기", +) -> None: + """㉥ 제잡비 윗단을 쓰면서 물빼기 파이프를 또 세지 않았는가. + + 품셈 13-6-2 [주]③ 이 **윗단 값에 파이프 설치의 노무비·재료비가 포함**된다고 + 적어 두었다. 그 값을 쓰면서 파이프를 자재로 또 실으면 같은 것을 두 번 센다. + + ⚠ 둘 중 하나만 골라야 한다 — 아랫단(파이프 미설치) 값을 쓰고 파이프를 따로 세거나, + 윗단 값을 쓰고 파이프를 안 세거나. + """ + if overhead_tier != OVERHEAD_TIER_WITH_PIPE: + return + tight = {"".join(str(name).split()) for name in material_names} + hit = next( + (name for name in DRAIN_PIPE_NAMES if "".join(name.split()) in tight), + None, + ) + if hit is not None: + raise DoubleCountError( + f"{label}: 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 「{hit}」을 자재로 또 " + "실었습니다 — 윗단 값에 파이프의 노무비·재료비가 이미 들어 있습니다 " + "(품셈 13-6-2 [주]③)." + ) + + +#: 큰돌쌓기(13-6) 품에 **이미 들어 있는** 자재 — 따로 세우면 두 번이다. +#: 근거: 품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」. +#: ⚠ **13-6 한정**이다 — 돌쌓기(13-4)·돌붙임(13-7)에는 이 [주]가 없으므로 +#: 그쪽에서 고임돌이 자재로 오는 것은 정상이다. 넓게 잡으면 정상 자재를 지운다. +BOULDER_INCLUDED_MATERIALS = ("고임돌", "채움콘크리트", "채움 콘크리트") +BOULDER_WORK_ITEM_PREFIX = "FP-13-06" + + +def check_included_materials_not_listed( + *, + work_item_codes: list[str], + materials: list[dict], + name_field: str = "material_name", + source_field: str = "source_structure", + label: str = "큰돌쌓기", +) -> None: + """㉦ 품에 포함된 자재를 따로 세지 않았는가 (품셈 13-6 [주]①). + + 큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면 + 같은 것을 두 번 센다. 자재의 `source_structure` 로 **그 구조물에서 온 것만** 본다 — + 다른 구조물(돌쌓기 13-4)의 고임돌은 정상이다. + """ + if not any(str(code).startswith(BOULDER_WORK_ITEM_PREFIX) for code in work_item_codes): + return + for material in materials: + name = "".join(str(material.get(name_field) or "").split()) + if name not in {"".join(x.split()) for x in BOULDER_INCLUDED_MATERIALS}: + continue + sources = material.get(source_field) or [] + if any(label in str(source) for source in sources): + raise DoubleCountError( + f"{label}: 「{material.get(name_field)}」이 자재로도 실렸습니다 — " + "큰돌쌓기 품에 이미 들어 있습니다 (품셈 13-6 [주]① 「고임돌 및 " + "채움 콘크리트 등은 품에 포함」)." + ) diff --git a/B09_Estimation/B09_Estimation_MachineCost.py b/B09_Estimation/B09_Estimation_MachineCost.py new file mode 100644 index 00000000..21fdb3a2 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineCost.py @@ -0,0 +1,213 @@ +"""B09 원가계산 — 기계경비 (PLAN 9-3 의 `S` → `X` 두 단계). + +실무·교본이 같은 두 단계다 (신규 문서 5장 라-1 예제도 같은 모양): + + S 취득가(천원) ──(손료계수)──► X 시간당 중기사용료 + = 손료(경비) + 연료(재료) + 운전사(노무) + +이 모듈이 내는 것은 **시간당 사용료 한 시간분**이고, 3분할(재료·노무·경비)로 낸다. +`B09_Estimation_PriceBook` 의 `MACHINE_BASE`(S) · `MACHINE_HOURLY`(X) 층에 그대로 앉는다. + +데이터 (`resources/data_cost_input_value/mach_base_2026.json`) + - `mach_price` **613 기종** — `machine_code` + `specification` 으로 규격이 갈린다. + - `mach_loss_coef` **387건** — 시간당 손료계수·내용시간·연간표준시간. + - ⚠ `mach_fuel_rate` **0건** · `mach_operator_map` **0건** — **비어 있다.** + 연료소모량과 기종별 운전사 직종은 품셈 본문에 있고, 아직 뽑히지 않았다. + +⚠ **그래서 이 모듈은 손료(경비)까지만 채우고, 연료·운전사는 「공백」으로 표시한다.** +0 으로 때우면 **시간당 사용료가 절반 이하로 나오고 그대로 총액에 섞인다** — 실측 +비중이 노무 53 % · 재료 20 % · 경비 27 % 라 손료만으로는 4분의 1 남짓이다. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_Guards import ( + check_operator_hours_basis, + reject_efficiency_in_hourly_rate, +) +from B09_Estimation.B09_Estimation_PriceBook import Money3 + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") +_THOUSAND = Decimal(1000) + + +#: 조종원 노임을 시간당으로 바꿀 때 나누는 시간. +#: 근거 — 품셈 8-1-6 기계손료 단서 「**관리비는 1일 8시간 초과해도 8시간으로 계산**」이 +#: 1일을 8시간으로 잡는다. 노임표도 `hours_per_day: 8` 이다. +#: ⚠ **실무 관측은 이보다 짧은 시간으로 나눈 사례가 있다** — STC 2024 관측 +#: 굴착기 0.7㎥ 노무 55,700 원/hr 은 건설기계운전사 일당 283,297 을 **약 5.09시간**으로 +#: 나눈 값이다(작업효율 `E`(실작업시간율)를 시간당 사용료에 미리 반영한 것으로 보임). +#: 품셈에서 작업효율은 **작업량 산정**(`Q = n·q·f·E`, 8-1-4)에 들어가지 시간당 사용료에 +#: 들어가지 않으므로 **8시간을 유지한다.** 관측에 맞추려 나눗수를 바꾸지 않는다 +#: (★법대로 PLAN 8-10). TODO(미결 PLAN 9-6): 발주처가 실가동시간을 요구하는 사례 확인. +OPERATOR_HOURS_PER_DAY = 8 + + +class MachineCostError(LookupError): + """기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다.""" + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _read_json(file_name: str) -> dict[str, Any]: + with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h: + return json.load(h) + + +@dataclass(frozen=True) +class MachineSpec: + """기종 한 줄 — 규격까지 붙어야 한 대가 정해진다.""" + + machine_code: str + name: str + specification: str + price_thousand_krw: Decimal + loss_coefficient_per_hour: Decimal | None = None + economic_life_hours: int | None = None + annual_standard_hours: int | None = None + + @property + def display_name(self) -> str: + return f"{self.name} {self.specification}".strip() + + +@dataclass +class MachineCatalog: + """613 기종. **이름만으로는 못 고른다** — 규격이 있어야 한 대가 정해진다.""" + + machines: dict[str, MachineSpec] = field(default_factory=dict) + + def by_name(self, name: str) -> list[MachineSpec]: + return [m for m in self.machines.values() if m.name == name] + + def resolve(self, name: str, specification: str) -> MachineSpec | None: + """이름 + 규격으로 한 대를 고른다. 규격이 없으면 **고르지 않는다**.""" + found = self.by_name(name) + if not found: + return None + if len(found) == 1 and not specification: + return found[0] + narrowed = [m for m in found if m.specification == specification] + return narrowed[0] if len(narrowed) == 1 else None + + def get(self, machine_code: str) -> MachineSpec: + try: + return self.machines[machine_code] + except KeyError as exc: + raise MachineCostError(f"기종 카탈로그에 없는 코드입니다: {machine_code}") from exc + + +def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatalog: + """취득가 613건에 손료계수 387건을 붙여 카탈로그 한 벌을 만든다.""" + variables = _read_json(file_name)["variables"] + coefficients = { + r["machine_code"]: r for r in variables.get("mach_loss_coef", {}).get("records", []) + } + + catalog = MachineCatalog() + for row in variables.get("mach_price", {}).get("records", []): + code = row["machine_code"] + coefficient = coefficients.get(code, {}) + catalog.machines[code] = MachineSpec( + machine_code=code, + name=row["machine_name"], + specification=str(row.get("specification", "")), + price_thousand_krw=Decimal(str(row["price_thousand_krw"])), + loss_coefficient_per_hour=( + Decimal(str(coefficient["loss_coefficient_per_hour"])) + if "loss_coefficient_per_hour" in coefficient + else None + ), + economic_life_hours=coefficient.get("economic_life_hours"), + annual_standard_hours=coefficient.get("annual_standard_hours"), + ) + return catalog + + +def hourly_loss_cost(machine: MachineSpec) -> Decimal: + """시간당 손료 = 취득가 × 손료계수. + + 취득가가 **천원 단위**라 원으로 환산한다 — 이 단위를 놓치면 1,000배 틀린다. + """ + if machine.loss_coefficient_per_hour is None: + raise MachineCostError( + f"{machine.display_name}: 손료계수가 없습니다 (취득가만 있는 기종 226건 중 하나)" + ) + return machine.price_thousand_krw * _THOUSAND * machine.loss_coefficient_per_hour + + +@dataclass +class HourlyMachineCost: + """시간당 중기사용료 — 3분할과 **채우지 못한 성분 목록**을 함께 낸다.""" + + machine: MachineSpec + money: Money3 + gaps: list[str] = field(default_factory=list) + + @property + def is_complete(self) -> bool: + return not self.gaps + + +def hourly_machine_cost( + machine: MachineSpec, + *, + fuel_liters_per_hour: Decimal | None = None, + fuel_price_per_liter: Decimal | None = None, + operator_daily_wage: Decimal | None = None, + operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY, + efficiency_factor: Decimal | None = None, +) -> HourlyMachineCost: + """시간당 사용료 한 시간분. + + 손료(경비)는 카탈로그로 바로 나온다. **연료(재료)·운전사(노무)는 값을 주지 않으면 + 0 으로 때우지 않고 `gaps` 에 적어 돌려준다** — 빠진 채로 총액에 섞이는 것이 + 이 자리에서 제일 위험하다. + """ + # ㉣ 작업효율이 이 자리에 들어오면 멈춘다 — 효율은 작업량 산정에만 들어간다. + reject_efficiency_in_hourly_rate( + efficiency_factor, where=f"{machine.display_name} 시간당 사용료" + ) + + gaps: list[str] = [] + expense = hourly_loss_cost(machine) + + material = Decimal(0) + if fuel_liters_per_hour is None or fuel_price_per_liter is None: + # TODO(미결 PLAN 9-6): `mach_fuel_rate` 0건 — 연료소모량이 품셈 본문에만 있다. + gaps.append("연료소모량(L/hr) 미확보 — 재료비 성분 비어 있음") + else: + material = fuel_liters_per_hour * fuel_price_per_liter + + labor = Decimal(0) + if operator_daily_wage is None: + # TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다. + gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음") + else: + labor = operator_daily_wage / Decimal(operator_hours_per_day) + # ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다. + check_operator_hours_basis( + labor_per_hour=labor, + daily_wage=operator_daily_wage, + hours_per_day=operator_hours_per_day, + ) + + return HourlyMachineCost( + machine=machine, + money=Money3(material=material, labor=labor, expense=expense), + gaps=gaps, + ) + + +def catalog_gaps(catalog: MachineCatalog) -> dict[str, int]: + """카탈로그 자체의 공백 — 몇 기종이 손료계수를 못 가졌나.""" + missing = [m for m in catalog.machines.values() if m.loss_coefficient_per_hour is None] + return {"machines": len(catalog.machines), "without_loss_coefficient": len(missing)} diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py new file mode 100644 index 00000000..f0ea7134 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -0,0 +1,350 @@ +"""B09 원가계산 — 운전경비(연료·잡재료·조종원)를 품셈 8-4 에서 뽑는다. + +`mach_base_2026.json` 의 `mach_fuel_rate`·`mach_operator_map` 이 **0건**이라 시간당 +중기사용료의 **재료비·노무비 성분이 비어 있었다**(PLAN 9-3). 그 값은 건설품셈 +**8-4 운전경비 산정** 표에 있다. + +**파생 파일로 낸다** — 기준자료(`resources/data_cost_input_value/`)를 고치지 않는다. +그 파일은 `_manifest.json` 지문으로 재현성을 거는 자리이고 원천 xlsx 에서 다시 +생성되는 물건이라, 값을 채워 넣으면 ① 이미 그 판으로 계산한 스냅샷과 대조가 깨지고 +② 다음 재생성 때 날아가고 ③ 주인이 겹친다. +⚠ **길게 보면 기준자료 원본이 채워지는 것이 맞다** — `mach_fuel_rate` 가 그 파일 안에 +**빈 변수로 선언**돼 있다는 것이 「원래 거기 들어갈 값」이라는 뜻이다. 원천 재생성 몫이라 +미결로 올려 둔다. + +⚠ **표가 열 단위로 뭉쳐 있다.** PDF 에서 뽑히며 한 열이 한 칸에 공백으로 이어 붙었다 — +`분류번호` 칸 하나에 코드 31개, `주연료` 칸 하나에 값 31개가 들어 있다. 그래서 +**위치로 짝짓고, 짝이 안 맞으면 그 표를 통째로 버린다.** 어긋난 채로 짝지으면 다른 +기종의 연료값이 조용히 붙는다. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") +_OUTPUT_SUBPATH = ("resources", "data_cost_machine_operating") + +#: 품셈 8-4 운전경비 표의 머리글 — 이 여섯이 다 있어야 그 표로 본다. +_REQUIRED_HEADERS = ("분류번호", "기계명", "규격", "주연료", "잡재료", "조종원") + +_RE_CODE_FULL = re.compile(r"^(\d{4})-(\d{4})$") +_RE_CODE_TAIL = re.compile(r"^\d{4}$") +_RE_DECIMAL = re.compile(r"^\d+(?:\.\d+)?$") + +#: 연료 종류가 값 앞에 붙는 경우 — 「휘발유0.7」·「중유487.2」. +_RE_FUEL_WITH_KIND = re.compile(r"^(휘발유|중유|경유)?(\d+(?:\.\d+)?)$") + +#: 조종원 직종 — 품셈 표는 「인/일」 수만 주고 직종명을 안 준다. +#: TODO(미결 PLAN 9-6): 기종별 직종이 품셈 다른 장에 있다. 아래는 `aliases` 기반 **잠정**이며 +#: 결과에 `operator_mapping_is_provisional: true` 로 드러난다. +_OPERATOR_TRUCK_WORDS = ("덤프트럭", "트럭", "트레일러", "화물") +_OPERATOR_ALIAS_TRUCK = "labor_op_truck" +_OPERATOR_ALIAS_CONSTRUCTION = "labor_op_const" + + +class OperatingCostError(ValueError): + """운전경비 표를 못 읽은 경우. 어긋난 채로 짝짓지 않는다.""" + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _read_json(*parts: str) -> dict[str, Any]: + with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle: + return json.load(handle) + + +@dataclass(frozen=True) +class OperatingRecord: + """기종 하나의 운전경비 원단위.""" + + machine_code: str + machine_name: str + specification: str + fuel_liters_per_hour: Decimal | None + fuel_kind: str + misc_material_percent: Decimal | None + operator_person_days: Decimal | None + operator_occupation_code: str = "" + operator_mapping_is_provisional: bool = True + + def as_dict(self) -> dict[str, Any]: + return { + "machine_code": self.machine_code, + "machine_name": self.machine_name, + "specification": self.specification, + "fuel_liters_per_hour": ( + None if self.fuel_liters_per_hour is None else str(self.fuel_liters_per_hour) + ), + "fuel_kind": self.fuel_kind, + "misc_material_percent": ( + None if self.misc_material_percent is None else str(self.misc_material_percent) + ), + "operator_person_days": ( + None if self.operator_person_days is None else str(self.operator_person_days) + ), + "operator_occupation_code": self.operator_occupation_code, + "operator_mapping_is_provisional": self.operator_mapping_is_provisional, + } + + +@dataclass +class OperatingParseResult: + records: list[OperatingRecord] = field(default_factory=list) + #: 짝이 안 맞아 버린 표 — 조용히 넘기지 않고 센다. + dropped_tables: list[str] = field(default_factory=list) + + +def _tokens(cell: str) -> list[str]: + return [t for t in re.split(r"\s+", str(cell or "").strip()) if t] + + +def expand_codes(tokens: list[str]) -> list[str]: + """`0201-0012 0020 0040` → `0201-0012 · 0201-0020 · 0201-0040`. + + 뒤 코드는 앞 코드의 **앞 네 자리를 이어받는다**. 이어받을 앞자리가 없으면 버린다. + """ + codes: list[str] = [] + prefix = "" + for token in tokens: + full = _RE_CODE_FULL.match(token) + if full: + prefix = full.group(1) + codes.append(token) + continue + if _RE_CODE_TAIL.match(token) and prefix: + codes.append(f"{prefix}-{token}") + continue + return [] # 코드 열이 아닌 표 + return codes + + +def _parse_fuel(token: str) -> tuple[Decimal | None, str]: + """「11.6」·「휘발유0.7」·「-」 를 값과 연료 종류로 가른다.""" + text = token.strip() + if text in ("-", "–", ""): + return None, "" + match = _RE_FUEL_WITH_KIND.match(text) + if not match: + return None, "" + kind = match.group(1) or "경유" + return Decimal(match.group(2)), kind + + +def _parse_percent(token: str) -> Decimal | None: + text = token.strip().rstrip("%") + return Decimal(text) if _RE_DECIMAL.match(text) else None + + +def _parse_person_days(token: str) -> Decimal | None: + text = token.strip() + return Decimal(text) if _RE_DECIMAL.match(text) else None + + +def _operator_code(machine_name: str, aliases: dict[str, str]) -> str: + """기종 이름으로 운전사 직종을 고른다 — **잠정 규칙**. + + 품셈 8-4 표는 「조종원 인/일」 수만 주고 직종명을 안 준다. 노임표의 `aliases` 가 + 운전사 직종 셋(`labor_op_const`·`labor_op_truck`·`labor_op_general`)을 들고 있어 + 트럭 계열만 화물차운전사로, 나머지는 건설기계운전사로 **잠정** 매핑한다. + """ + key = ( + _OPERATOR_ALIAS_TRUCK + if any(word in machine_name for word in _OPERATOR_TRUCK_WORDS) + else _OPERATOR_ALIAS_CONSTRUCTION + ) + return aliases.get(key, "") + + +def parse_operating_tables( + pum: dict[str, Any], + aliases: dict[str, str], +) -> OperatingParseResult: + """품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.""" + result = OperatingParseResult() + for table in pum.get("tables", []): + headers = table.get("headers") or [] + joined = " ".join(headers) + if not all(word in joined for word in _REQUIRED_HEADERS): + continue + + for row in table.get("rows") or []: + if len(row) < 6: + continue + codes = expand_codes(_tokens(row[0])) + names = _tokens(row[1]) + specs = _tokens(row[2]) + fuels = _tokens(row[3]) + miscs = _tokens(row[4]) + operators = _tokens(row[5]) + + # ⚠ 위치로 짝짓는다 — 개수가 안 맞으면 그 줄을 통째로 버린다. + if not codes or not (len(codes) == len(specs) == len(fuels) == len(operators)): + result.dropped_tables.append(f"{table.get('section', '')[:40]} (개수 불일치)") + continue + + # ⚠ 이름 칸은 **표 전체의 기종명이 한 덩어리로** 들어온다 + # (「불도저(무한궤도)불도저(타이어)습지불도저굴착기(무한궤도)…」). + # 코드별로 못 가르므로 **이름은 여기서 안 쓴다** — 613 기종 카탈로그에서 + # `machine_code` 로 찾아 붙인다(`enrich_with_catalog`). + _ = names + for index, code in enumerate(codes): + fuel, kind = _parse_fuel(fuels[index]) + misc = _parse_percent(miscs[index]) if index < len(miscs) else None + result.records.append( + OperatingRecord( + machine_code=code, + machine_name="", # 카탈로그에서 채운다 + specification=specs[index], + fuel_liters_per_hour=fuel, + fuel_kind=kind, + misc_material_percent=misc, + operator_person_days=_parse_person_days(operators[index]), + operator_occupation_code="", # 이름을 안 뒤 정한다 + ) + ) + return result + + +def enrich_with_catalog( + result: OperatingParseResult, + catalog: Any, + aliases: dict[str, str], +) -> OperatingParseResult: + """코드로 613 기종 카탈로그에서 **이름·규격을 가져와** 채운다. + + 품셈 표의 이름 칸은 통째로 뭉쳐 와 못 쓴다. 코드가 정본이다. + 카탈로그에 없는 코드는 **버리고 목록에 남긴다** — 이름 없는 기종은 단가가 못 붙는다. + """ + enriched: list[OperatingRecord] = [] + for record in result.records: + machine = catalog.machines.get(record.machine_code) + if machine is None: + result.dropped_tables.append(f"{record.machine_code} (기종 카탈로그에 없음)") + continue + enriched.append( + OperatingRecord( + machine_code=record.machine_code, + machine_name=machine.name, + specification=machine.specification or record.specification, + fuel_liters_per_hour=record.fuel_liters_per_hour, + fuel_kind=record.fuel_kind, + misc_material_percent=record.misc_material_percent, + operator_person_days=record.operator_person_days, + operator_occupation_code=_operator_code(machine.name, aliases), + ) + ) + result.records = enriched + return result + + +def load_operating_records( + pum_file: str = "pum_const_2026.json", + labor_file: str = "labor_const_2026-01-01.json", +) -> OperatingParseResult: + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"] + aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {}) + parsed = parse_operating_tables(pum, aliases) + return enrich_with_catalog(parsed, load_machine_catalog(), aliases) + + +def write_operating_records( + result: OperatingParseResult, + *, + source_dataset_version: dict[str, str], + output_dir: str | None = None, +) -> str: + """파생 파일로 낸다. **어느 기준자료 판에서 파생됐는지**를 함께 적는다.""" + directory = output_dir or os.path.join(_project_root(), *_OUTPUT_SUBPATH) + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, "machine_operating_2026.json") + + payload = { + "schema_version": "1.0", + "dataset_id": "machine_operating_derived", + "derived_from": source_dataset_version, + "note": ( + "건설품셈 8-4 운전경비 산정에서 뽑은 파생본. 기준자료가 아니다 — " + "`mach_fuel_rate`·`mach_operator_map` 이 채워지면 이 파일은 걷어낸다." + ), + "policy": { + "operator_mapping_is_provisional": True, + "operator_mapping_rule": "트럭 계열 = 화물차운전사, 그 밖 = 건설기계운전사 (잠정)", + }, + "stats": {"records": len(result.records), "dropped": len(result.dropped_tables)}, + "records": [r.as_dict() for r in result.records], + "dropped_tables": result.dropped_tables, + } + with open(path, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + return path + + +def load_fuel_price(oil_file: str = "oil_2026-08-14.json") -> tuple[Decimal, dict[str, str]]: + """경유 단가와 그 판의 신원. + + ⚠ **전국평균이다.** 품셈 8-1-7 5호는 「유류가격은 **해당 지역의 가격**」이라 + 규정하므로 나중에 현장 소재지 값으로 갈아끼울 자리다 (TODO 미결 PLAN 9-6). + 지역 파라미터 자리만 뚫어 두고 지금은 전국평균을 잠정으로 쓴다. + """ + payload = _read_json(*_CATALOG_SUBPATH, oil_file) + diesel = payload["variables"]["oil_diesel"] + return Decimal(str(diesel["value"])), { + "dataset_id": payload.get("dataset_id", ""), + "effective_date": payload.get("effective_date", ""), + "scope": diesel.get("scope", ""), + } + + +def load_operator_wages(labor_file: str = "labor_const_2026-01-01.json") -> dict[str, Decimal]: + """직종코드 → 일당.""" + records = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"]["labor_rate"]["records"] + return { + str(r["occupation_code"]): Decimal(str(r["daily_wage_krw"])) + for r in records + if "daily_wage_krw" in r + } + + +def hourly_cost_of(machine_code: str, *, region: str | None = None): + """기종 하나의 **시간당 사용료 3분할**을 완성해 돌려준다. + + 재료비 = 주연료 × 유가 + 잡재료(주연료의 %) / 노무비 = 조종원 일당 ÷ 8시간 / + 경비 = 손료. `region` 은 유가 지역값 자리 — 지금은 전국평균만 있어 무시된다. + """ + from B09_Estimation.B09_Estimation_MachineCost import hourly_machine_cost, load_machine_catalog + + catalog = load_machine_catalog() + machine = catalog.get(machine_code) + records = {r.machine_code: r for r in load_operating_records().records} + record = records.get(machine_code) + if record is None: + return hourly_machine_cost(machine) + + fuel_price, _ = load_fuel_price() + wages = load_operator_wages() + + liters = record.fuel_liters_per_hour + if liters is not None and record.misc_material_percent is not None: + # 잡재료는 **주연료의 %** 라 유가와 같이 움직인다(PLAN 8-18 유가 민감분). + liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) + + wage = wages.get(record.operator_occupation_code) + if record.operator_person_days is not None and wage is not None: + wage = wage * record.operator_person_days + + return hourly_machine_cost( + machine, + fuel_liters_per_hour=liters, + fuel_price_per_liter=fuel_price if liters is not None else None, + operator_daily_wage=wage, + ) diff --git a/B09_Estimation/B09_Estimation_MachineProductivity.py b/B09_Estimation/B09_Estimation_MachineProductivity.py new file mode 100644 index 00000000..5a328f03 --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity.py @@ -0,0 +1,360 @@ +"""B09 원가계산 — 기계 시공능력 `Q` (품셈 8-1-4, PLAN 9-5 ③). + +**왜 있는가** — 토공 주요 공종(흙깎기·측구터파기·성토)의 품셈 표는 **소요량표가 아니다.** +「인력 10 % + 장비 90 %」로 갈리고, 장비 몫은 자원 수량이 아니라 **공식의 계수** +(`K`·`f`·`E`·`Cm`)로 적혀 있다. 그래서 표를 베끼면 **인력 몫만 서고 장비 몫이 통째로 +빠진다** — 2026-09-08 실측으로 측구터파기가 인력 10 % 몫(39,575.6원/㎥)만으로 서 있었다. + +공식 (지식DB `05_원가정보/기계경비_산정.md` §4 — 품셈 8-1-4) + + Q = n · q · K · f · E n = 3600 ÷ Cm (시간당 싸이클 수) + + q 1싸이클 표준작업량 (버킷 용량 ㎥ — **기종 규격에서 온다**) + K 버킷계수 (표의 `K`·`k`) + f 체적환산계수 (표의 `f`) + E 작업효율 = 현장능력계수 × 실작업시간율 (표의 `E`) + Cm 1싸이클 소요시간(초) (표의 `㎝(sec)` — 원문 표기가 「㎝」이지 센티미터가 아니다) + + 수량 1단위당 기계 소요시간(hr) = 1 ÷ Q → × 시간당 사용료 = 그 공종의 기계경비 + +⚠ **㉣ 와 어긋나지 않는다** (PLAN 9-6). ㉣ 는 「작업효율 `E` 를 **시간당 사용료** 쪽에 +넣지 말라」이고, 품셈이 `E` 를 넣으라는 자리가 **바로 여기(작업량 `Q`)** 다. 그러므로 +`reject_efficiency_in_hourly_rate()` 는 이 모듈에서 **부르지 않는다** — 부르면 정상 +계산이 멈추는 오탐이 된다. 진짜 위반은 **같은 `E` 를 `Q` 와 사용료에 둘 다 넣는 것**이라, +그쪽은 사용료 계산 자리(`B09_Estimation_MachineCost`)의 가드가 그대로 지킨다. + +⚠ **모르는 값을 지어내지 않는다.** 표가 범위(「0.55∼0.45」)만 주고 확정값을 안 주면 +계수를 못 세운 것으로 보고 `FactorGap` 으로 드러낸다 — 가운데값을 임의로 취하지 않는다 +(CLAUDE.md 3장). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog +from B09_Estimation.B09_Estimation_ResourceAxis import RANGE_DASH_CLASS + +_ZERO = Decimal(0) +_SECONDS_PER_HOUR = Decimal(3600) + +#: 표의 행 머리 — 대문자·소문자가 섞여 온다(`K` 와 `k` 가 같은 표 안에 있다). +_KEY_BUCKET = ("k",) +_KEY_VOLUME = ("f",) +_KEY_EFFICIENCY = ("e",) +_KEY_CYCLE = ("㎝(sec)", "cm(sec)", "cm", "㎝") + +#: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다. +#: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다. +#: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다. +MACHINE_NAME_ALIASES = { + "유압식백호우": "굴착기", + "백호우": "굴착기", + "백호": "굴착기", + "유압식굴삭기": "굴착기", + "굴삭기": "굴착기", +} + +#: 무한궤도·타이어 갈래. 카탈로그 이름이 「굴착기(무한궤도)」처럼 갈래를 품고 있다. +_TRACK_WORDS = ("무한궤도", "타이어", "습지") + +_RE_PARENS = re.compile(r"[((]([^))]*)[))]") +_RE_NUMBER = re.compile(r"-?\d+(?:\.\d+)?") +_RE_FRACTION = re.compile(r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)$") +#: 범위 표기 — 「0.55∼0.45」·「0.2~0.8」. **확정값이 아니다.** +_RE_RANGE = re.compile(rf"\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?") + + +class ProductivityError(ValueError): + """시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다.""" + + +def parse_measure(cell: str) -> Decimal | None: + """계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.** + + 읽는 모양 — 「0.77」 · 「1/1.30」(분수) · 「20(135°)」(괄호는 조건 설명이라 버린다). + 안 읽는 모양 — 「0.55∼0.45」(범위) · 「육상과동일」(참조) · 빈 칸. + """ + text = str(cell).strip() + if not text: + return None + if _RE_RANGE.search(text): + return None # 범위는 확정값이 아니다 — 가운데를 임의로 취하지 않는다 + fraction = _RE_FRACTION.match(text) + if fraction: + divisor = Decimal(fraction.group(2)) + return None if divisor == 0 else Decimal(fraction.group(1)) / divisor + # 괄호 안은 조건 설명(각도 등)이므로 떼고 본다 — 「20(135°)」 → 20 + outside = _RE_PARENS.sub("", text).strip() + found = _RE_NUMBER.search(outside) + return Decimal(found.group(0)) if found else None + + +@dataclass(frozen=True) +class CycleFactors: + """한 표에서 뽑아낸 시공능력 계수 한 벌.""" + + work_item_code: str + pum_table_id: str + machine_code: str + machine_name: str + bucket_capacity_m3: Decimal # q + bucket_coefficient: Decimal # K + volume_factor: Decimal # f + efficiency: Decimal # E — **작업량 쪽에만 들어간다** (㉣) + cycle_seconds: Decimal # Cm + #: 인력 몫 배분율(%) — 「인력(10%)」이면 `10`. 없으면 `None`. + labor_ratio_pct: Decimal | None = None + machine_ratio_pct: Decimal | None = None + + @property + def formula_text(self) -> str: + return ( + f"Q = 3600 ÷ {self.cycle_seconds} × {self.bucket_capacity_m3} × " + f"{self.bucket_coefficient} × {self.volume_factor} × {self.efficiency}" + ) + + +@dataclass(frozen=True) +class FactorGap: + """계수를 못 세운 표. **빈칸으로 두지 않고 무엇이 없는지 적는다.**""" + + work_item_code: str + pum_table_id: str + missing: tuple[str, ...] + note: str = "" + + +def hourly_output(factors: CycleFactors) -> Decimal: + """시간당 작업량 `Q` (㎥/hr). + + `Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. + """ + if factors.cycle_seconds <= 0: + raise ProductivityError( + f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다." + ) + cycles_per_hour = _SECONDS_PER_HOUR / factors.cycle_seconds + output = ( + cycles_per_hour + * factors.bucket_capacity_m3 + * factors.bucket_coefficient + * factors.volume_factor + * factors.efficiency + ) + if output <= 0: + raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") + return output + + +def machine_hours_per_unit(factors: CycleFactors) -> Decimal: + """수량 1단위당 기계 소요시간(hr). 여기에 시간당 사용료를 곱하면 기계경비가 된다.""" + return Decimal(1) / hourly_output(factors) + + +def resolve_machine(cell: str) -> tuple[str, str] | None: + """표의 기계 이름 셀을 기종 카탈로그 한 줄로 푼다. + + 「유압식백호우 (무한궤도,0.7㎥)」 → `0201-0070` 굴착기(무한궤도) 0.7. + **이름과 규격이 둘 다 맞을 때만** 고른다 — 규격이 안 맞으면 안 고른다. + """ + text = str(cell).strip() + if not text: + return None + inside = " ".join(_RE_PARENS.findall(text)) + head = _RE_PARENS.sub("", text).strip() + name = MACHINE_NAME_ALIASES.get(head.replace(" ", ""), head) + + capacity = parse_measure(_capacity_token(inside)) + track = next((word for word in _TRACK_WORDS if word in inside), "") + if capacity is None: + return None + + catalog = load_machine_catalog() + for code, machine in catalog.machines.items(): + if name not in machine.name: + continue + if track and track not in machine.name: + continue + spec = parse_measure(machine.specification) + if spec is not None and spec == capacity: + return code, f"{machine.name} {machine.specification}" + return None + + +def _capacity_token(inside: str) -> str: + """괄호 안에서 용량 토막만 뽑는다 — 「무한궤도,0.7㎥」 → 「0.7㎥」.""" + for token in re.split(r"[,,]", inside): + if any(unit in token for unit in ("㎥", "m3", "M3", "루베")): + return token + return "" + + +def extract_cycle_factors( + work_item_code: str, + table: dict[str, Any], +) -> CycleFactors | FactorGap | None: + """표 하나에서 계수를 뽑는다. + + 공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면 + `FactorGap`, 다 있으면 `CycleFactors`. + """ + rows = table.get("raw_row") or [] + values: dict[str, Decimal] = {} + machine: tuple[str, str] | None = None + bucket_from_machine_row: Decimal | None = None + ratios: dict[str, Decimal] = {} + saw_key = False + + for row in rows: + cells = [str(c).strip() for c in row] + if not cells: + continue + head = cells[0].lower().replace(" ", "") + rest = cells[1:] + + # 「장비(90%) | 유압식백호우 (무한궤도,0.7㎥) | k | 0.9」 모양 + for index, cell in enumerate(cells): + found = resolve_machine(cell) + if found is not None and machine is None: + machine = found + bucket_from_machine_row = parse_measure( + _capacity_token(" ".join(_RE_PARENS.findall(cell))) + ) + # 같은 줄 뒤쪽에 「k | 0.9」가 붙어 오는 표가 있다 + tail = cells[index + 1 :] + for position, token in enumerate(tail): + if token.lower() in _KEY_BUCKET and position + 1 < len(tail): + parsed = parse_measure(tail[position + 1]) + if parsed is not None: + values["K"] = parsed + break + + ratio = _ratio_of(cells[0]) + if ratio is not None: + label = "labor" if "인력" in cells[0] else "machine" if "장비" in cells[0] else "" + if label: + ratios[label] = ratio + + if head in _KEY_BUCKET: + saw_key = True + values.setdefault("K", _first_measure(rest)) + elif head in _KEY_VOLUME: + saw_key = True + values.setdefault("f", _first_measure(rest)) + elif head in _KEY_EFFICIENCY: + saw_key = True + values.setdefault("E", _first_measure(rest)) + elif head in _KEY_CYCLE: + saw_key = True + values.setdefault("Cm", _first_measure(rest)) + + if not saw_key and machine is None: + return None + + missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None] + capacity = bucket_from_machine_row + if machine is None: + missing.append("기계") + if capacity is None: + missing.append("q(버킷 용량)") + if missing: + return FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=tuple(missing), + note="표가 확정값 대신 범위·참조만 주었거나 기종을 못 골랐습니다.", + ) + + return CycleFactors( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + machine_code=machine[0], + machine_name=machine[1], + bucket_capacity_m3=capacity, + bucket_coefficient=values["K"], + volume_factor=values["f"], + efficiency=values["E"], + cycle_seconds=values["Cm"], + labor_ratio_pct=ratios.get("labor"), + machine_ratio_pct=ratios.get("machine"), + ) + + +def _first_measure(cells: list[str]) -> Decimal | None: + """그 행에서 **처음 읽히는 확정값**. 뒤 칸은 유도식·참조라 앞 칸이 우선이다.""" + for cell in cells: + parsed = parse_measure(cell) + if parsed is not None: + return parsed + return None + + +_RE_RATIO = re.compile(r"[((]\s*(\d+(?:\.\d+)?)\s*%\s*[))]") + + +def _ratio_of(cell: str) -> Decimal | None: + found = _RE_RATIO.search(str(cell)) + return Decimal(found.group(1)) if found else None + + +def attach_machine_share( + book: Any, + factor_gaps: dict[str, FactorGap], + cycle_factors: dict[str, CycleFactors], + master: dict[str, Any], + work_item_code: str, + title_code: str, +) -> Decimal: + """시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다. + + ⚠ 불도저 쪽(`attach_dozer_share`)과 **자리를 나눠 쓴다** — 두 식이 한 일위대가에 + 붙으면 장비를 두 번 세는 것이 된다. + + 계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은 + `partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다). + """ + node = next( + (w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code), + None, + ) + if node is None: + return _ZERO + + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + for table in node.get("tables", []): + factors = extract_cycle_factors(work_item_code, table) + if not isinstance(factors, CycleFactors): + if isinstance(factors, FactorGap): + factor_gaps[work_item_code] = factors + continue + hourly_code = f"X-{factors.machine_code}" + if hourly_code not in book.titles: + # 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다. + factor_gaps[work_item_code] = FactorGap( + work_item_code=work_item_code, + pum_table_id=factors.pum_table_id, + missing=("시간당 사용료",), + note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", + ) + continue + share = ( + Decimal(1) + if factors.machine_ratio_pct is None + else Decimal(str(factors.machine_ratio_pct)) / Decimal(100) + ) + book.add_detail( + PriceDetail( + title_code, + hourly_code, + machine_hours_per_unit(factors) * share, + note=factors.formula_text, + ) + ) + cycle_factors[work_item_code] = factors + return share * Decimal(100) + return _ZERO diff --git a/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py new file mode 100644 index 00000000..8ff27a9b --- /dev/null +++ b/B09_Estimation/B09_Estimation_MachineProductivity_Dozer.py @@ -0,0 +1,439 @@ +"""B09 원가계산 — **불도저** 시공능력 (건설품셈 8-2-1). + +**굴착기(8-1-4)와 식이 다르다.** 한 파일에 두면 700 줄을 넘고, 무엇보다 두 식이 +섞여 읽힌다 — 실제로 임도 불도저 표(FP-10-11)가 굴착기 식으로 읽혀 「K·Cm 없음」으로 +잘못 진단되고 있었다(2026-09-08). + + Q = 60 ÷ cm · (q₀ × e) · f · E cm = L/V1 + L/V2 + t + t = 기어 변속시간 0.25 분 + + q₀ 거리를 고려하지 않은 삽날 용량(㎥) · e 운반거리계수 · L 운반거리(m) + V1 전진속도(m/분) · V2 후진속도(m/분) · f 체적환산계수 · E 작업효율 + +⚠ **밑수가 60(분)이다.** 굴착기는 3600(초)이라 섞으면 60 배 어긋난다. + +⚠ **표가 기종 이름을 안 적는다.** 임도 표는 `q₀`·`V1`·`V2` 만 주므로 8-2-1 표에서 +규격을 **되짚는다** — 하나로 안 좁혀지면 안 고른다(지어내지 않는다). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog +from B09_Estimation.B09_Estimation_MachineProductivity import ( + CycleFactors, + FactorGap, + ProductivityError, + _first_measure, + extract_cycle_factors, + parse_measure, +) + +_ZERO = Decimal(0) + + +#: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.** +#: {규격(ton): {단: (전진, 후진)}} +#: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은 +#: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다. +#: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반 +#: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다. +_DOZER_SPEEDS = { + "무한궤도": { + Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))}, + Decimal("7"): { + 1: (Decimal(43), Decimal(53)), + 2: (Decimal(67), Decimal(78)), + 3: (Decimal(92), Decimal(107)), + }, + Decimal("10"): { + 1: (Decimal(42), Decimal(50)), + 2: (Decimal(64), Decimal(75)), + 3: (Decimal(88), Decimal(105)), + }, + Decimal("12"): { + 1: (Decimal(40), Decimal(48)), + 2: (Decimal(55), Decimal(70)), + 3: (Decimal(75), Decimal(100)), + }, + Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))}, + Decimal("19"): { + 1: (Decimal(40), Decimal(46)), + 2: (Decimal(55), Decimal(70)), + 3: (Decimal(75), Decimal(98)), + }, + Decimal("32"): { + 1: (Decimal(40), Decimal(43)), + 2: (Decimal(52), Decimal(58)), + 3: (Decimal(70), Decimal(78)), + }, + }, + "타이어": { + Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))}, + Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))}, + Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))}, + }, +} +#: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」 +_DOZER_GEAR_SHIFT_MIN = Decimal("0.25") +_MINUTES_PER_HOUR = Decimal(60) + + +@dataclass(frozen=True) +class DozerFactors: + """불도저 시공능력 계수 한 벌 (건설품셈 8-2-1).""" + + work_item_code: str + blade_capacity_m3: Decimal # q₀ + distance_factor: Decimal # e + volume_factor: Decimal # f + efficiency: Decimal # E + haul_distance_m: Decimal # L + forward_speed_m_min: Decimal # V1 + reverse_speed_m_min: Decimal # V2 + #: 표가 기종 이름을 안 적어 `q゚`·`V1`·`V2` 로 되짚은 결과(`resolve_dozer`). + machine_code: str = "" + machine_name: str = "" + + @property + def cycle_minutes(self) -> Decimal: + """cm = L/V1 + L/V2 + t — **분**이다.""" + return ( + self.haul_distance_m / self.forward_speed_m_min + + self.haul_distance_m / self.reverse_speed_m_min + + _DOZER_GEAR_SHIFT_MIN + ) + + @property + def formula_text(self) -> str: + return ( + f"Q = 60 ÷ {self.cycle_minutes:.4f}분 × ({self.blade_capacity_m3} × " + f"{self.distance_factor}) × {self.volume_factor} × {self.efficiency}" + ) + + +def dozer_hourly_output(factors: DozerFactors) -> Decimal: + """불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다.""" + if factors.cycle_minutes <= 0: + raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.") + blade = factors.blade_capacity_m3 * factors.distance_factor + output = ( + _MINUTES_PER_HOUR + / factors.cycle_minutes + * blade + * factors.volume_factor + * factors.efficiency + ) + if output <= 0: + raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.") + return output + + +def dozer_speeds( + tonnage: Decimal, gear: int = 2, track: str = "무한궤도" +) -> tuple[Decimal, Decimal] | None: + """그 규격·그 단의 전진·후진 속도. 표에 없으면 `None` — 지어내지 않는다.""" + return _DOZER_SPEEDS.get(track, {}).get(tonnage, {}).get(gear) + + +#: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다. +#: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다. +_DOZER_BLADE_M3 = { + ("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지 + ("무한궤도", Decimal("7")): Decimal("1.1"), + ("무한궤도", Decimal("10")): Decimal("1.5"), + ("무한궤도", Decimal("12")): Decimal("2.0"), + ("무한궤도", Decimal("13")): Decimal("1.5"), # 습지 + ("무한궤도", Decimal("19")): Decimal("3.2"), + ("무한궤도", Decimal("32")): Decimal("5.5"), + ("타이어", Decimal("15")): Decimal("3.1"), + ("타이어", Decimal("28")): Decimal("4.0"), + ("타이어", Decimal("33")): Decimal("5.7"), +} + +#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」. +_DOZER_WET_TONS = (Decimal("4"), Decimal("13")) + +#: 표의 머리말. ⚠ **`e` 와 `E` 는 대소문자만 다르고 뜻이 전혀 다르다** — +#: `e` 는 운반거리계수, `E` 는 작업효율이다. 그래서 이 표는 **소문자로 내려 읽으면 안 된다** +#: (굴착기 쪽 `extract_cycle_factors` 는 내려 읽는다 — 그쪽엔 `e` 가 없어 안전하다). +_DOZER_SINGLE_KEYS = { + "L": "L", + "q0": "q0", + "q゚": "q0", + "q₀": "q0", + "e": "e", + "V1": "V1", + "V2": "V2", + "t": "t", +} +#: 갈래를 거느리는 머리말 — 「E | 토사 | 0.55」 아래에 「암석 | 0.25」가 딸려 온다. +_DOZER_GROUP_KEYS = ("E", "f") + +_RE_GEAR = re.compile(r"(\d+)\s*단") + + +def resolve_dozer( + blade_m3: Decimal, + forward: Decimal, + reverse: Decimal, + gear: int = 2, +) -> tuple[str, str] | None: + """삽날 용량과 속도로 **불도저 기종을 되짚는다**. + + 임도 품셈 표는 기종 이름을 안 적고 `q゚`·`V1`·`V2` 만 준다. 그 셋이 8-2-1 표에서 + 한 규격만 가리킬 때 그 기종으로 본다 — **둘 이상이면 안 고른다**(지어내지 않는다). + + q゚ 3.2㎥ + 55/70 m/분(2단) → 불도저(무한궤도) 19 톤 + + ⚠ **단(gear)까지 맞아야 한다** — 같은 삽날이라도 단이 다르면 다른 규격을 가리킨다. + """ + candidates = [] + for (track, tonnage), blade in _DOZER_BLADE_M3.items(): + if blade != blade_m3: + continue + if dozer_speeds(tonnage, gear, track) == (forward, reverse): + candidates.append((track, tonnage)) + if len(candidates) != 1: + return None + + track, tonnage = candidates[0] + if track == "무한궤도" and tonnage in _DOZER_WET_TONS: + wanted = "습지 불도저" + else: + wanted = f"불도저({track})" + for code, machine in load_machine_catalog().machines.items(): + if machine.name == wanted and parse_measure(machine.specification) == tonnage: + return code, f"{machine.name} {machine.specification}" + return None + + +def dozer_machine_hours_per_unit(factors: DozerFactors) -> Decimal: + """수량 1단위당 불도저 소요시간(hr).""" + return Decimal(1) / dozer_hourly_output(factors) + + +def extract_dozer_factors( + work_item_code: str, + table: dict[str, Any], +) -> dict[str, DozerFactors] | FactorGap | None: + """불도저 표 하나에서 **갈래별** 계수를 뽑는다 (품셈 8-2-1). + + 돌려주는 것 — 불도저 표가 아니면 `None`, 계수가 모자라면 `FactorGap`, + 다 서면 `{갈래: DozerFactors}` (토사·파쇄암·발파암처럼 `f` 갈래마다 한 벌). + + ⚠ **딸린 줄은 한 칸 왼쪽으로 밀려 온다** — 머리 줄은 「f | 토사 | 1/1.30」이고 + 다음 줄은 「파쇄암 | 1/1.35」다. 자리를 그대로 읽으면 갈래가 통째로 빠진다. + """ + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return None + + single: dict[str, Decimal] = {} + groups: dict[str, dict[str, Decimal]] = {"E": {}, "f": {}} + gears: dict[str, int] = {} + current: str | None = None + + for cells in rows: + if not cells or not cells[0]: + continue + head = cells[0] + if head in _DOZER_SINGLE_KEYS: + current = None + key = _DOZER_SINGLE_KEYS[head] + value = _first_measure(cells[1:]) + if value is not None: + single[key] = value + found = _RE_GEAR.search(" ".join(cells[1:])) + if found: + gears[key] = int(found.group(1)) + elif head in _DOZER_GROUP_KEYS: + current = head + label = cells[1] if len(cells) > 1 else "" + value = _first_measure(cells[2:]) + if value is None: + # 갈래가 없는 표 — 「f | 1/1.3」처럼 값이 바로 붙는다. 갈래 이름은 빈 문자열. + value = _measure_with_adjustment(label) + label = "" + if value is not None: + groups[head][_normalize_label(label)] = value + elif current is not None: + # 딸린 줄 — 「파쇄암 | 1/1.35」. 값이 없으면 갈래 줄이 아니다. + value = _first_measure(cells[1:]) + if value is not None: + groups[current][_normalize_label(head)] = value + + if not groups["f"] or "q0" not in single or "V1" not in single: + return None # 불도저 표가 아니다 + + missing = [key for key in ("L", "q0", "e", "V1", "V2", "t") if key not in single] + if not groups["E"]: + missing.append("E(작업효율)") + if missing: + return FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=tuple(missing), + note="불도저 표(8-2-1)인데 계수가 모자랍니다.", + ) + + gear = gears.get("V1", gears.get("V2", 2)) + machine = resolve_dozer(single["q0"], single["V1"], single["V2"], gear) + if machine is None: + return FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=("기계",), + note=( + f"삽날 {single['q0']}㎥ · {single['V1']}/{single['V2']}m/분({gear}단) " + "으로는 규격이 하나로 안 좁혀집니다." + ), + ) + + built: dict[str, DozerFactors] = {} + for label, volume_factor in groups["f"].items(): + efficiency = _dozer_efficiency(groups["E"], label) + if efficiency is None: + continue # 그 갈래의 작업효율이 없다 — 가운데값을 지어내지 않는다 + built[label] = DozerFactors( + work_item_code=work_item_code, + blade_capacity_m3=single["q0"], + distance_factor=single["e"], + volume_factor=volume_factor, + efficiency=efficiency, + haul_distance_m=single["L"], + forward_speed_m_min=single["V1"], + reverse_speed_m_min=single["V2"], + machine_code=machine[0], + machine_name=machine[1], + ) + return built or FactorGap( + work_item_code=work_item_code, + pum_table_id=str(table.get("pum_table_id", "")), + missing=("E(갈래별 작업효율)",), + note="`f` 갈래에 맞는 작업효율을 못 골랐습니다.", + ) + + +_RE_ADJUSTED = re.compile(r"^\(?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\)?$") + + +def _measure_with_adjustment(cell: str) -> Decimal | None: + """계수 한 칸. **「(0.55-0.1)」 같은 보정식도 읽는다.** + + 8-2-1 [주]④⑤ 가 「정지작업을 겸하면 0.1 을, 터파기는 0.05 를 뺀 값」이라 적어, + 임도 표가 뺄셈을 그대로 적어 온다(비다듬기·정지 9-17-2). ⚠ **범위(「0.55∼0.45」)와 + 헷갈리면 안 된다** — 범위는 확정값이 아니라 `parse_measure` 가 `None` 을 낸다. + 여기서 읽는 것은 **하이픈 뺄셈 한 가지**뿐이다. + """ + found = _RE_ADJUSTED.match(" ".join(str(cell).split())) + if found: + return Decimal(found.group(1)) - Decimal(found.group(2)) + return parse_measure(cell) + + +def _normalize_label(text: str) -> str: + return "".join(str(text).split()) + + +def _dozer_efficiency(efficiencies: dict[str, Decimal], label: str) -> Decimal | None: + """그 갈래의 작업효율 `E`. + + ⚠ 표가 `f` 는 「토사·파쇄암·발파암」으로 잘게 주고 `E` 는 「토사·암석」으로 굵게 준다. + 그래서 **암 갈래는 「암석」 줄을 쓴다** — 그 표가 암을 한 값으로 묶어 준 것이다. + """ + if label in efficiencies: + return efficiencies[label] + if "암" in label: + for key, value in efficiencies.items(): + if "암" in key: + return value + return efficiencies.get("토사") if len(efficiencies) == 1 else None + + +def dozer_variants(node: dict[str, Any]) -> list[str]: + """그 공종이 불도저 공식으로 세울 수 있는 갈래 이름들. 아니면 빈 목록.""" + labels: list[str] = [] + for table in node.get("tables", []): + found = extract_dozer_factors(str(node.get("work_item_code", "")), table) + if isinstance(found, dict): + labels.extend(label for label in found if label not in labels) + return labels + + +def formula_machine_codes(master: dict[str, Any]) -> set[str]: + """**공식표에서만 드러나는 기종 코드.** + + ⚠ 자원 축에는 안 나온다 — 표가 기계를 「줄」로 안 적고 계수로만 적기 때문이다. + 이 코드를 시간당 사용료 층에 안 넣으면, 공식은 다 서 놓고 **붙일 사용료가 없어** + 빈 일위대가가 남는다(2026-09-08 불도저 운반에서 실제로 그랬다). + """ + codes: set[str] = set() + for node in master.get("work_items", []): + code = str(node.get("work_item_code", "")) + for table in node.get("tables", []): + factors = extract_cycle_factors(code, table) + if isinstance(factors, CycleFactors): + codes.add(factors.machine_code) + found = extract_dozer_factors(code, table) + if isinstance(found, dict): + codes.update(f.machine_code for f in found.values() if f.machine_code) + return codes + + +def attach_dozer_share( + book: Any, + factor_gaps: dict[str, FactorGap], + master: dict[str, Any], + work_item_code: str, + title_code: str, + variant: str, +) -> Decimal: + """불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0. + + ⚠ 굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에 + 붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을 + 때만** 굴착기 쪽으로 간다. + """ + from B09_Estimation.B09_Estimation_PriceBook import PriceDetail + + node = next( + (w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code), + None, + ) + if node is None: + return _ZERO + wanted = _normalize_label(variant) + for table in node.get("tables", []): + found = extract_dozer_factors(work_item_code, table) + if isinstance(found, FactorGap): + factor_gaps[work_item_code] = found + continue + if not isinstance(found, dict): + continue + factors = found.get(wanted) + if factors is None: + continue + hourly_code = f"X-{factors.machine_code}" + if hourly_code not in book.titles: + factor_gaps[work_item_code] = FactorGap( + work_item_code=work_item_code, + pum_table_id=factors.work_item_code, + missing=("시간당 사용료",), + note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.", + ) + continue + book.add_detail( + PriceDetail( + title_code, + hourly_code, + dozer_machine_hours_per_unit(factors), + note=factors.formula_text, + ) + ) + return Decimal(100) + return _ZERO diff --git a/B09_Estimation/B09_Estimation_MaterialCatalog.py b/B09_Estimation/B09_Estimation_MaterialCatalog.py new file mode 100644 index 00000000..8fe5721b --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialCatalog.py @@ -0,0 +1,157 @@ +"""B09 원가계산 — 자재 카탈로그 (PLAN 9-3 · 9-4). + +**관급과 사급을 처음부터 가른다.** 섞어 두면 나중에 못 가른다 — 관급은 +**총원가 밖 별도 표기 + 조달수수료**라 계산 자리가 아예 다르다(PLAN 8-2 인계 6필드). + +구분 이름은 두 창이 맞춘 것을 쓴다 (2026-09-07 확정): + - `supply_type` = `owner_supplied`(관급) / `contractor_supplied`(사급) + - `owner_supplied_install_by` = `contractor`(도급자설치) / `owner` / `None` + ⚠ **모르면 `None` 으로 두고 「설치 주체 미지정」으로 드러낸다.** 안전관리비 대상액이 + **관급 전액이 아니라 도급자설치분**을 쓰므로(PLAN 8-10), 잘못 찍으면 금액이 조용히 + 틀린다. + +원천 + - 관급 = `mat_price_public_2026-08-14.json` — 나라장터 **6,999건**. + `vat_basis: "부가가치세별도"` 라 **부가세 제외 단가**이고 원가에 그대로 쓴다. + ⚠ 철근·레미콘·아스콘은 그 파일의 `excluded_named_groups` 로 **빠져 있다**. + - 사급 = **없다.** 유료 물가지 미결(No.18). **값을 지어내지 않고 공백으로 드러낸다.** + +⚠ **자재 단가는 할증 전 값이다** (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳에서만 붙인다. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") + +#: 두 창이 맞춘 구분 이름 — 값을 바꾸면 B08 자재총괄과 안 맞는다. +SUPPLY_OWNER = "owner_supplied" +SUPPLY_CONTRACTOR = "contractor_supplied" +INSTALL_BY_CONTRACTOR = "contractor" +INSTALL_BY_OWNER = "owner" + + +class MaterialCatalogError(LookupError): + """자재 단가를 못 세운 경우. 0 으로 때우지 않는다.""" + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _read_json(file_name: str) -> dict[str, Any]: + with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h: + return json.load(h) + + +@dataclass(frozen=True) +class MaterialItem: + """자재 한 줄. **단가는 할증 전·부가세 제외 값**이다.""" + + item_code: str + name: str + specification: str + unit: str + price_krw: Decimal + supply_type: str + #: 관급일 때만 뜻이 있다. `None` = **설치 주체 미지정**(안전관리비 대상액에 못 넣음). + owner_supplied_install_by: str | None = None + vat_excluded: bool = True + notice_date: str = "" + + @property + def display_name(self) -> str: + return f"{self.name} {self.specification}".strip() + + +@dataclass +class MaterialCatalog: + """자재 목록. **이름만으로는 못 고른다** — 같은 품명에 규격이 여럿이다.""" + + items: dict[str, MaterialItem] = field(default_factory=dict) + #: 채우지 못한 것 — 사급 미결·제외 품목. **빈칸이 아니라 목록으로 든다.** + gaps: list[str] = field(default_factory=list) + + def by_name(self, name: str) -> list[MaterialItem]: + return [m for m in self.items.values() if m.name == name] + + def resolve(self, name: str, specification: str) -> MaterialItem | None: + """품명 + 규격으로 한 줄을 고른다. 규격이 없으면 **고르지 않는다**. + + 6,999건 중 같은 품명이 수십 개인 것이 흔하다 — 이름만 맞추면 엉뚱한 규격의 + 단가가 조용히 붙는다. + """ + found = self.by_name(name) + if not found: + return None + if len(found) == 1 and not specification: + return found[0] + narrowed = [m for m in found if m.specification == specification] + return narrowed[0] if len(narrowed) == 1 else None + + def get(self, item_code: str) -> MaterialItem: + try: + return self.items[item_code] + except KeyError as exc: + raise MaterialCatalogError(f"자재 카탈로그에 없는 코드입니다: {item_code}") from exc + + def count_by_supply(self) -> dict[str, int]: + counts: dict[str, int] = {} + for item in self.items.values(): + counts[item.supply_type] = counts.get(item.supply_type, 0) + 1 + return counts + + +def load_material_catalog( + public_file: str = "mat_price_public_2026-08-14.json", +) -> MaterialCatalog: + """관급 자재를 읽고, 사급은 **없다는 사실을 목록으로** 남긴다.""" + payload = _read_json(public_file) + catalog = MaterialCatalog() + + for row in payload["variables"]["mat_price"]["records"]: + code = str(row["item_code"]) + catalog.items[code] = MaterialItem( + item_code=code, + name=row.get("classification_name", ""), + specification=row.get("specification", ""), + unit=row.get("unit", ""), + price_krw=Decimal(str(row.get("price_krw", 0))), + supply_type=SUPPLY_OWNER, + # ⚠ 나라장터 자료에 설치 주체가 없다 — 지어내지 않고 미지정으로 둔다. + owner_supplied_install_by=None, + vat_excluded=row.get("vat_basis", "") == "부가가치세별도", + notice_date=str(row.get("notice_datetime", ""))[:10], + ) + + # 사급 — 원천이 아직 없다. **값을 지어내지 않는다.** + catalog.gaps.append( + "사급 자재 단가 없음 — 유료 물가지 미결(No.18). 6번 슬롯(적용 단가) 수동 입력으로 채웁니다." + ) + for group in payload.get("excluded_named_groups", []): + catalog.gaps.append(f"관급 제외 품목: {group.get('group', '')} — {group.get('reason', '')}") + return catalog + + +def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]: + """화면에 낼 요약 — **무엇이 없는지**를 함께 낸다.""" + unspecified = [ + m + for m in catalog.items.values() + if m.supply_type == SUPPLY_OWNER and m.owner_supplied_install_by is None + ] + return { + "items": len(catalog.items), + "by_supply": catalog.count_by_supply(), + "owner_supplied_install_unspecified": len(unspecified), + "gaps": list(catalog.gaps), + "notes": [ + "자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.", + "관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.", + ], + } diff --git a/B09_Estimation/B09_Estimation_MaterialSheet.py b/B09_Estimation/B09_Estimation_MaterialSheet.py new file mode 100644 index 00000000..135b36ff --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialSheet.py @@ -0,0 +1,180 @@ +"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」). + +**무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다. +수량은 B08 것이 정본이고 여기서 다시 세지 않는다. + +**관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1) + - **사급** — 도급 재료비. 내역서 안에 들어간다. + - **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의 + 「관급자재대」와 같은 값이라 그쪽과 이어야 한다. + - **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간 + 총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다. + +⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을 +반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가 +`rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다. + +⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은 +대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MaterialCatalog import ( + SUPPLY_CONTRACTOR, + SUPPLY_OWNER, + MaterialCatalog, + load_material_catalog, +) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + +_ZERO = Decimal(0) + +#: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다. +SUPPLY_UNKNOWN = "unknown" + +#: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08). +SURCHARGE_APPLIED = "applied" +SURCHARGE_NOT_APPLIED = "not_applied" +SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" + + +@dataclass +class MaterialSheetRow: + """자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다.""" + + name: str + spec: str + unit: str + net_amount: Decimal + total_amount: Decimal + supply_type: str + unit_price_krw: Decimal | None = None + amount_krw: Decimal | None = None + surcharge_pct: Decimal | None = None + source_structure: tuple[str, ...] = () + note: str = "" + + def as_dict(self) -> dict[str, Any]: + def money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + return { + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "net_amount": str(self.net_amount), + "total_amount": str(self.total_amount), + "supply_type": self.supply_type, + "unit_price_krw": money(self.unit_price_krw), + "amount_krw": money(self.amount_krw), + "surcharge_pct": money(self.surcharge_pct), + "source_structure": list(self.source_structure), + "note": self.note, + } + + +@dataclass +class MaterialSheet: + """자재대 한 벌 — 사급·관급·미정 셋으로 갈린다.""" + + contractor_rows: list[MaterialSheetRow] = field(default_factory=list) + owner_rows: list[MaterialSheetRow] = field(default_factory=list) + unknown_rows: list[MaterialSheetRow] = field(default_factory=list) + #: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.** + missing: list[dict[str, str]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + @property + def contractor_total_krw(self) -> Decimal: + """사급 자재비 합계 — 도급 재료비로 들어간다.""" + return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO) + + @property + def owner_total_krw(self) -> Decimal: + """관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다.""" + return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO) + + def as_dict(self) -> dict[str, Any]: + return { + "contractor": [row.as_dict() for row in self.contractor_rows], + "owner": [row.as_dict() for row in self.owner_rows], + "unknown": [row.as_dict() for row in self.unknown_rows], + "contractor_total_krw": str(self.contractor_total_krw), + # 관급자재대는 **천원 올림** 자리다(단수처리 규칙). + "owner_total_krw": str( + round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL) + ), + "missing": self.missing, + "notes": self.notes, + } + + +def build_material_sheet( + materials: list, + *, + surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, + catalog: MaterialCatalog | None = None, +) -> MaterialSheet: + """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" + book = catalog or load_material_catalog() + sheet = MaterialSheet() + + if surcharge_status == SURCHARGE_RATE_UNAVAILABLE: + sheet.notes.append( + "할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 " + "붙습니다 (PLAN 8-7 ㉠)." + ) + + for material in materials: + row = MaterialSheetRow( + name=getattr(material, "material_name", ""), + spec=getattr(material, "spec", ""), + unit=getattr(material, "unit", ""), + net_amount=getattr(material, "net_amount", _ZERO), + total_amount=getattr(material, "total_amount", _ZERO), + supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN), + surcharge_pct=getattr(material, "surcharge_pct", None), + source_structure=tuple(getattr(material, "source_structure", ()) or ()), + ) + + if row.supply_type == SUPPLY_UNKNOWN: + # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. + row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다." + sheet.unknown_rows.append(row) + sheet.missing.append( + {"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"} + ) + continue + + found = book.resolve(row.name, row.spec) + if found is None: + row.note = ( + "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " + "6번 슬롯(적용 단가) 수동 입력 대기." + ) + sheet.missing.append( + {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} + ) + else: + row.unit_price_krw = found.price_krw + # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. + row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) + + if row.supply_type == SUPPLY_OWNER: + sheet.owner_rows.append(row) + elif row.supply_type == SUPPLY_CONTRACTOR: + sheet.contractor_rows.append(row) + else: + sheet.unknown_rows.append(row) + + if sheet.owner_rows: + sheet.notes.append( + "관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 " + "관급자재대와 같은 값이어야 합니다." + ) + return sheet diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py new file mode 100644 index 00000000..dc35bb2b --- /dev/null +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -0,0 +1,165 @@ +"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3). + +**무엇인가** — 내역서 한 줄의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는 +줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다 +(8-13 관측). STC 실측도 `D01341 절토(토사) 굴삭기0.7㎥ m³ 1,939` 처럼 **`D` 가 `B` +(일위대가)를 참조하는 한 층 위**였다. + + D 단가산출 → B 일위대가 → X 시간당 사용료 → S·M·L 카탈로그 + +⚠ **표를 세 벌 만들지 않는다** (PLAN 9-3). `PriceBook` 의 「제목 + 상세」 한 쌍에 +`kind` 만 `PRICE_BASIS` 로 얹는다 — 일위대가와 같은 구조, 같은 화면 모양이다. + +⚠ **번호는 코드에 박지 않는다.** 실무 참조번호(「단산 46」)는 **그 내역서 안에서의 +차례**라 프로젝트마다 다르다. 코드(`D-FP-…`)는 공종을 가리키고, 번호는 조판할 때 매긴다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build + +_ONE = Decimal(1) + + +@dataclass +class PriceBasisEntry: + """단가산출서 한 장 — 참조번호 + 그 줄이 무엇을 참조하는지.""" + + number: int + code: str + name: str + spec: str + unit: str + unit_price_krw: Decimal + #: 이 산출서가 참조하는 일위대가 코드. 화면에서 눌러 내려가는 자리. + ref_code: str + + @property + def label(self) -> str: + """내역서 비고에 적는 문구 — 실무 서식 그대로 「단산 46 참조」.""" + return f"단산 {self.number} 참조" + + def as_dict(self) -> dict[str, Any]: + return { + "number": self.number, + "label": self.label, + "code": self.code, + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "unit_price_krw": str(self.unit_price_krw), + "ref_code": self.ref_code, + } + + +@dataclass +class PriceBasisSheet: + """그 내역서에 딸린 단가산출서 한 벌.""" + + entries: list[PriceBasisEntry] = field(default_factory=list) + + def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None: + return next((entry for entry in self.entries if entry.ref_code == ref_code), None) + + def as_dict(self) -> dict[str, Any]: + return {"entries": [entry.as_dict() for entry in self.entries]} + + +def build_price_basis( + unit_price_codes: list[str], + build: UnitPriceBuild | None = None, +) -> PriceBasisSheet: + """내역서에 쓰인 일위대가마다 산출서 한 장을 세운다. + + 번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다. + 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다. + """ + prices = build or cached_build() + sheet = PriceBasisSheet() + seen: set[str] = set() + + for code in unit_price_codes: + if not code or code in seen or code not in prices.book.titles: + continue + seen.add(code) + title = prices.book.title(code) + money = prices.book.resolve(code) + basis_code = f"D-{code[2:]}" if code.startswith("B-") else f"D-{code}" + + if basis_code not in prices.book.titles: + prices.book.add_title( + PriceTitle( + code=basis_code, + kind=PriceKind.PRICE_BASIS, + name=title.name, + spec=title.spec, + unit=title.unit, + ) + ) + # ⚠ 지금은 **일위대가를 그대로 한 줄로** 참조한다. 할증·기타 비용이 붙는 + # 자리가 생기면 여기에 줄이 는다 — 구조를 미리 열어 둔다. + prices.book.add_detail(PriceDetail(basis_code, code, _ONE, note="일위대가 그대로")) + + sheet.entries.append( + PriceBasisEntry( + number=len(sheet.entries) + 1, + code=basis_code, + name=title.name, + spec=title.spec, + unit=title.unit, + unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW), + ref_code=code, + ) + ) + return sheet + + +def price_basis_detail( + code: str, + build: UnitPriceBuild | None = None, +) -> dict[str, Any]: + """산출서 한 장의 본표 — 무엇을 참조해 그 단가가 나왔는지. + + 일위대가 본표와 **같은 모양**이라 화면이 같은 표를 쓴다. + """ + from B09_Estimation.B09_Estimation_UnitPrice import detail_of + + prices = build or cached_build() + title = prices.book.title(code) + rows: list[dict[str, Any]] = [] + for detail in prices.book.details.get(code, []): + child = prices.book.title(detail.ref_code) + money = prices.book.resolve(detail.ref_code).scaled(detail.quantity) + rows.append( + { + "code": detail.ref_code, + "name": child.name, + "spec": child.spec, + "unit": child.unit, + "quantity": str(detail.quantity), + "total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), + "drillable": True, + "note": detail.note, + } + ) + + money = prices.book.resolve(code) + return { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "rows": rows, + "total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), + "material": str(money.material), + "labor": str(money.labor), + "expense": str(money.expense), + # 한 층 아래(일위대가) 본표를 그대로 딸려 보낸다 — 화면이 두 번 물어보지 않게. + "unit_price": detail_of(prices, rows[0]["code"]) if rows else None, + } diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py new file mode 100644 index 00000000..fa09427a --- /dev/null +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -0,0 +1,257 @@ +"""B09 원가계산 — 단가 계층 (PLAN 9-3 · 9-4). + +**표를 세 벌 만들지 않는다.** 「제목 한 줄 + 상세 여러 줄」 한 쌍을 두고 **종류로만** 가른다. +상용 프로그램 둘(STmate `COSTN`/`BOQ11`, EST Plus `*Title`/`*Main`)이 같은 모양이었고, +실무 시트 이름도 `일위대가목록표 / 일위대가표` 처럼 짝을 이룬다. + +실제 층은 넷이다 (2026-09-07 STC `COSTN` 186행 실측, PLAN 9-3): + + S 중기 취득가(천원) → X 시간당 중기사용료 → B 일위대가 → D 단가산출 + ↑ L 노임 · M 자재를 참조 + +금액은 **어느 층이든 재료·노무·경비 3분할**이고 `합계 = 재료 + 노무 + 경비` 다 +(ESTX 9.1만 건 전건 통과). + +단가 원천은 **슬롯 6개**다 (PLAN 9-4). 번호는 고정, **이름은 프로젝트 설정**이다 — +설계사무소마다 다르다(영월만 「유통 물가·거래 가격등·기타 단가」). 기본 채택은 **6번** +(STmate `JUKNM=6`, Ini 보유 6파일 전건 일치). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from enum import Enum + +_ZERO = Decimal(0) + +#: 단가 원천 슬롯 수. 번호 고정. +PRICE_SLOT_COUNT = 6 +#: 기본 채택 슬롯(1-based). STmate `JUKNM=6` = 「적용 단가」. +DEFAULT_ADOPTED_SLOT = 6 + +#: 슬롯 이름 기본값 — **프로젝트 설정으로 덮어쓴다**. +#: TODO(미결 PLAN 9-4): 사무소마다 1~5 이름이 달라 확정 기본값이 아님. 프로젝트가 고름. +DEFAULT_SLOT_NAMES: tuple[str, ...] = ( + "조달가격", + "물가정보", + "물가자료", + "적산정보", + "견적단가", + "적용 단가", +) + + +class PriceKind(str, Enum): + """단가 항목의 종류. STC `COSTN.CODE` 앞글자와 1:1 (PLAN 9-3).""" + + MATERIAL = "material" # M — 자재 카탈로그 (재료비만) + LABOR = "labor" # L — 노임 카탈로그 (노무비만) + MACHINE_BASE = "machine_base" # S — 중기 취득가 (천원 단위, 경비만) + MACHINE_HOURLY = "machine_hourly" # X — 시간당 중기사용료 (3분할) + UNIT_PRICE = "unit_price" # B — 일위대가 (3분할) + PRICE_BASIS = "price_basis" # D — 단가산출 (3분할) + LUMPSUM = "lumpsum" # W — 일식·견적 (무대처리 등, 단가 0) + + +#: 카탈로그 층 — 상세를 갖지 않고 값이 바로 있는 종류. +CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHINE_BASE}) + + +class PriceBookError(LookupError): + """단가 조립이 성립하지 않는 경우. 0 으로 때우지 않고 멈춘다.""" + + +@dataclass(frozen=True) +class Money3: + """금액 3분할 — 재료·노무·경비. 합계는 셋의 합이다.""" + + material: Decimal = _ZERO + labor: Decimal = _ZERO + expense: Decimal = _ZERO + + @property + def total(self) -> Decimal: + return self.material + self.labor + self.expense + + def __add__(self, other: Money3) -> Money3: + return Money3( + self.material + other.material, + self.labor + other.labor, + self.expense + other.expense, + ) + + def scaled(self, factor: Decimal) -> Money3: + return Money3(self.material * factor, self.labor * factor, self.expense * factor) + + +@dataclass +class PriceTitle: + """제목 줄 — 「무엇이 있나」 한 줄. 실무 시트의 `…목록표` 에 해당.""" + + code: str + kind: PriceKind + name: str + spec: str = "" + unit: str = "" + + #: 원천 슬롯 6개. 값이 없는 슬롯은 None (그 출처에 안 실린 자재). + slots: list[Decimal | None] = field(default_factory=lambda: [None] * PRICE_SLOT_COUNT) + #: 슬롯별 근거 쪽수 — 실무 내역서가 「물가정보 몇 쪽」을 남긴다(STC `PG_` 열). + slot_pages: list[str | None] = field(default_factory=lambda: [None] * PRICE_SLOT_COUNT) + #: 채택 슬롯(1-based). + adopted_slot: int = DEFAULT_ADOPTED_SLOT + + def adopted_price(self) -> Decimal: + """채택 슬롯의 단가. 비어 있으면 0 으로 때우지 않고 멈춘다.""" + if not 1 <= self.adopted_slot <= PRICE_SLOT_COUNT: + raise PriceBookError( + f"{self.code}: 채택 슬롯 번호가 범위 밖입니다 ({self.adopted_slot})" + ) + value = self.slots[self.adopted_slot - 1] + if value is None: + raise PriceBookError( + f"{self.code} ({self.name}): 채택 슬롯 {self.adopted_slot} 에 단가가 없습니다. " + "유료 물가지를 안 봤다면 6번(적용 단가)에 직접 넣으십시오." + ) + return value + + def catalog_money(self) -> Money3: + """카탈로그 층의 3분할 — 종류가 성분을 정한다. + + 자재는 재료비만, 노임은 노무비만, 중기 취득가는 경비만 갖는다(STC 실측). + """ + price = self.adopted_price() + if self.kind is PriceKind.MATERIAL: + return Money3(material=price) + if self.kind is PriceKind.LABOR: + return Money3(labor=price) + if self.kind is PriceKind.MACHINE_BASE: + return Money3(expense=price) + raise PriceBookError(f"{self.code}: 카탈로그 종류가 아닙니다 ({self.kind})") + + +@dataclass +class PriceDetail: + """상세 줄 — 「그것이 무엇으로 이루어졌나」 한 줄. + + `ref_code` 가 **원천 참조**다. 어느 층을 가리키는지가 그 코드의 종류로 드러난다 + (ESTX `LinkIndex` 와 같은 축). + """ + + parent_code: str + ref_code: str + quantity: Decimal + note: str = "" + #: 비율 행(공구손료 등) — 참조 단가의 %로 계산하는 줄. + percent_of_parent: Decimal | None = None + #: **노무비 합계**의 %로 붙는 경비 줄 — 제잡비(품셈 13-6-1 [주]③). + #: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고, + #: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다. + percent_of_labor: Decimal | None = None + + +@dataclass +class PriceBook: + """제목 + 상세 한 벌. 종류로만 갈린다.""" + + titles: dict[str, PriceTitle] = field(default_factory=dict) + details: dict[str, list[PriceDetail]] = field(default_factory=dict) + #: 슬롯 이름 — 프로젝트 설정. + slot_names: tuple[str, ...] = DEFAULT_SLOT_NAMES + + def add_title(self, title: PriceTitle) -> None: + if title.code in self.titles: + raise PriceBookError(f"코드가 겹칩니다: {title.code}") + self.titles[title.code] = title + + def add_detail(self, detail: PriceDetail) -> None: + self.details.setdefault(detail.parent_code, []).append(detail) + + def title(self, code: str) -> PriceTitle: + try: + return self.titles[code] + except KeyError as exc: + raise PriceBookError(f"단가표에 없는 코드입니다: {code}") from exc + + def resolve(self, code: str, _seen: tuple[str, ...] = ()) -> Money3: + """그 항목의 단가를 3분할로 조립한다. + + 카탈로그 층(M·L·S)은 값이 바로 있고, 그 위 층(X·B·D)은 상세 줄을 재귀로 더한다. + `W`(일식·견적)는 **단가가 0** 이다 — 무대처리처럼 품에 이미 포함된 줄 (PLAN 8-7 ㉡). + """ + if code in _seen: + raise PriceBookError(f"단가 참조가 돌고 있습니다: {' → '.join((*_seen, code))}") + + title = self.title(code) + if title.kind is PriceKind.LUMPSUM: + return Money3() + if title.kind in CATALOG_KINDS: + return title.catalog_money() + + rows = self.details.get(code) + if not rows: + raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다") + + total = Money3() + # 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다. + # 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋. + direct_labor = Decimal(0) + for row in rows: + # ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로 + # 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다). + if row.percent_of_labor is not None: + # 제잡비 — **노무비 합계**의 %가 **경비**로 붙는다(품셈 13-6-1 [주]③). + # + # ⚠ **밑수는 사람 품(직접노무비)이다** — 기계 줄 안의 조종원 노임은 + # 안 센다. 근거 셋(2026-09-08 원문 대조, 두 창 합의): + # ① 산림품셈 13-6-2 [주]③ — 「제잡비는 콘크리트 버켓 손료, 다짐기계 + # 손료 비용이며 **노무비의 합계액**에 위 표의 비율을 곱한 금액을 + # 상한으로 하여 계상한다」 + # ② 건설품셈 제8장 — 「잡재료 등 손료 : **직접노무비**에 다음 표의 + # 비율을 곱한 것을 상한으로 한다」 (같은 이름의 규정) + # ③ 같은 장 — 기계를 넣을 때는 「잡재료비 = **노무비, 기계손료 및 + # 운전경비의 합** × 잡재료비율」이라 **따로 적음** ⇒ 넓은 쪽이면 + # 명시하는 서식인데 13-6-2 는 그냥 「노무비」다. + # 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람 + # 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다. + # + # ⚠⚠ **닫힌 물음이다 — 다시 뒤집지 말 것** (2026-09-08 전수 대조로 확정). + # 이 값은 하루에 두 번 뒤집혔고 금액이 약 ±22 % 움직였다. 세 번째가 + # 없도록 근거를 여기 못 박는다. + # ㉠ **산림품셈이 1차 적용**이고(품셈 1-1·별표2, 건설품셈은 보완), + # 그 [주]③ 은 여섯 자리에서 한결같이 **「노무비의 합계액」**이라 적는다 + # (원문 L5498·L7013·L7299·L7323·L7350·L7401). + # ㉡ **일위대가 안에는 간접노무비가 없다.** 간접노무비는 원가계산서 층에서 + # 「직접노무비 × 율」로 나중에 생기는 값이다 + # (resources/knowledge/.../05_원가정보/원가계산_체계.md §2 밑수 정의표). + # ⇒ 그러므로 **그 표의 노무비 줄 합 = 직접노무비**이고, 「직접노무비냐 + # 노무비 계정이냐」라는 물음 자체가 이 층에서는 성립하지 않는다. + # ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로 + # 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다. + # ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다. + total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100)) + continue + + child = self.resolve(row.ref_code, (*_seen, code)) + if row.percent_of_parent is not None: + # 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등). + total = total + total.scaled(row.percent_of_parent / Decimal(100)) + continue + scaled = child.scaled(row.quantity) + if self.titles[row.ref_code].kind is PriceKind.LABOR: + direct_labor = direct_labor + scaled.labor + total = total + scaled + return total + + def unmatched_codes(self) -> list[str]: + """상세가 가리키는데 제목이 없는 코드 — **빈칸으로 두지 않고 목록으로 낸다**. + + 문자열 매칭 실패를 조용히 0 원으로 넘기지 않기 위한 자리 (PLAN 8-6). + """ + missing: list[str] = [] + for rows in self.details.values(): + for row in rows: + if row.ref_code not in self.titles and row.ref_code not in missing: + missing.append(row.ref_code) + return missing diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py new file mode 100644 index 00000000..0141c6a1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -0,0 +1,104 @@ +"""B09 원가계산 — **수량의 소수자리**는 종목마다 다르다 (산림품셈 1-2-2의 1). + +**왜 있는가** — 지금까지 화면이 **모든 수량을 2자리로** 찍고 있었는데, 품셈은 종목별로 +자리를 따로 정해 두었다. 체적합계·시멘트·철근은 **정수**, 돌쌓기·옹벽·떼는 **1자리**, +철강재는 **3자리**다. 2자리 고정은 그 어느 것도 아니다 (2026-09-08 지식DB 대조에서 드러남). + + 원문 = resources/knowledge/original/행정규칙/임도 품셈 적용기준 + (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md L575~660 + 요약 = resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md §2 + +⚠ **수량은 반올림이다** ([주]① 「본 표에 따르며, **반올림**하여 적용한다」). +금액이 전부 **버림**인 것과 반대다 — 한 모듈에 섞어 두면 반드시 헷갈리므로 +금액은 `B09_Estimation_Rounding` 이 따로 맡는다. + +⚠ **항목이 표를 이긴다** ([주]② 「품셈 각 항목에서 제시한 소숫자리가 본 표와 상이할 경우 +**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종 +마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다. + +⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만 +여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 — +「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다. +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal + +#: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다. +#: ⚠ 값을 고칠 일이 생기면 원문을 먼저 볼 것 — 여기서 고치면 근거가 끊긴다. +_DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( + # 종목 이름 후보 · 단위 후보 · 소수자리 + (("공사연장", "노선연장"), ("m",), 0), + (("공사폭원", "노폭"), ("m",), 1), + (("직공인부",), ("인",), 2), + (("체적합계", "토적합계"), ("㎥", "m3"), 0), + (("떼", "평떼", "줄떼"), ("㎡", "m2"), 1), + (("모래", "자갈", "조약돌"), ("㎥", "m3"), 2), + (("견치돌", "깬돌"), ("㎡", "m2"), 1), + (("야면석",), ("㎥", "m3"), 1), + (("돌쌓기", "돌붙임"), ("㎥", "m3", "㎡", "m2"), 1), + (("사석",), ("㎥", "m3"), 1), + (("다듬돌", "절석", "판석"), ("개",), 2), + (("벽돌", "블록"), ("개",), 0), + (("시멘트",), ("kg", "㎏"), 0), + (("모르타르", "모르터"), ("kg", "㎏"), 2), + (("콘크리트",), ("㎥", "m3"), 2), + (("합판",), ("장",), 1), + (("말뚝",), ("개",), 0), + (("철강재", "강재"), ("kg", "㎏"), 3), + (("용접봉",), ("kg", "㎏"), 1), + (("철근",), ("kg", "㎏"), 0), + (("볼트", "너트", "꺽쇠"), ("개",), 0), + (("철선", "철사"), ("kg", "㎏"), 2), + (("못",), ("kg", "㎏"), 2), + (("화약",), ("kg", "㎏"), 3), + (("뇌관",), ("개",), 0), + (("도화선",), ("m",), 1), + (("수로연장",), ("m",), 1), + (("옹벽",), ("㎡", "m2"), 1), + (("도장", "칠하기"), ("㎡", "m2"), 1), + (("방수",), ("㎡", "m2"), 1), + (("보오링", "보링"), ("m",), 1), +) + +#: 위 표에 이름이 안 걸릴 때 **단위만으로** 줄 수 있는 자리 — 품셈 「토적」 줄이 근거다. +#: 토적: 높이·너비 m 2 · 단면적 ㎡ 1 · 체적 ㎥ 2. +#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라 +#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다. +_UNIT_ONLY = {"㎥": 2, "m3": 2} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def digits_for(name: str, unit: str, spec: str = "") -> int | None: + """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.""" + haystack = _tight(name) + _tight(spec) + unit_tight = _tight(unit) + for words, units, digits in _DIGITS: + if unit_tight not in units: + continue + if any(word in haystack for word in words): + return digits + return _UNIT_ONLY.get(unit_tight) + + +def round_quantity( + value: Decimal, + name: str, + unit: str, + spec: str = "", + override: int | None = None, +) -> tuple[Decimal, int | None]: + """수량을 그 종목의 자리로 **반올림**한다. + + 돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`. + `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다. + """ + digits = override if override is not None else digits_for(name, unit, spec) + if digits is None: + return value, None + quantum = Decimal(1).scaleb(-digits) + return value.quantize(quantum, rounding=ROUND_HALF_UP), digits diff --git a/B09_Estimation/B09_Estimation_Rates.py b/B09_Estimation/B09_Estimation_Rates.py new file mode 100644 index 00000000..21ea9d21 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Rates.py @@ -0,0 +1,271 @@ +"""B09 원가계산 — 요율 데이터 로더·구간 조회. + +요율은 **코드에 박지 않는다**. `resources/data_cost_input_value/rates_*.json` 이 정본이고 +이 모듈은 그 파일을 읽어 구간을 골라 주는 일만 한다 (PLAN 9-2·8-10 ★법대로). + +핵심 규칙 (PLAN 8-9·8-10 — 실무 원가계산서 재현으로 확인): + - 요율표는 **한 벌**이다. 안전관리비 A/B 는 요율이 두 벌인 것이 아니라 + **같은 표를 대상액 두 개로 각각 조회**하는 것이다. + - 구간 라벨의 `billion` 은 **십억 원(10^9)**, `million` 은 **백만 원(10^6)** 이다. + `lt_5_billion` = 50억 미만. (2026-09-07 값 파일 대조로 확정) + - 판정 실패는 **조용히 넘기지 않는다** — 기본값으로 때우면 금액이 조용히 틀린다. +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from decimal import Decimal +from functools import lru_cache +from typing import Any + +# 구간 라벨의 단위 접미사 → 원(KRW) 배수. +_UNIT_MULTIPLIER: dict[str, int] = { + "million": 1_000_000, + "billion": 1_000_000_000, +} + +_RESOURCE_SUBPATH = ("resources", "data_cost_input_value") + +# 라벨 문법 — 숫자 구간만 해석한다. 그 밖(`turnkey_or_alternative` 등)은 명시 선택자로 고른다. +_RE_LT = re.compile(r"^lt_(\d+(?:\.\d+)?)_(million|billion)$") +_RE_GTE = re.compile(r"^gte_(\d+(?:\.\d+)?)_(million|billion)(?:_(.+))?$") +_RE_RANGE_ONE_UNIT = re.compile(r"^(\d+(?:\.\d+)?)_to_(\d+(?:\.\d+)?)_(million|billion)$") +_RE_RANGE_TWO_UNIT = re.compile( + r"^(\d+(?:\.\d+)?)_(million|billion)_to_(\d+(?:\.\d+)?)_(million|billion)$" +) +_RE_DAYS_LTE = re.compile(r"^lte_(\d+)_days$") +_RE_DAYS_GTE = re.compile(r"^gte_(\d+)_days$") +_RE_DAYS_RANGE = re.compile(r"^(\d+)_to_(\d+)_days$") + + +class RateLookupError(LookupError): + """요율 구간을 못 고른 경우. 기본값으로 때우지 않고 여기서 멈춘다.""" + + +@dataclass(frozen=True) +class RateDataset: + """요율 데이터셋 한 벌 — 재현성 표기용 신원(9-2)을 함께 든다.""" + + dataset_id: str + effective_date: str + sha256: str + variables: dict[str, Any] + + def variable(self, name: str) -> Any: + try: + return self.variables[name] + except KeyError as exc: # pragma: no cover - 데이터 파손 시에만 + raise RateLookupError(f"요율 항목이 데이터셋에 없습니다: {name}") from exc + + @property + def version_stamp(self) -> dict[str, str]: + """내역서·화면에 남길 「어느 판으로 계산했나」 표기.""" + return { + "dataset_id": self.dataset_id, + "effective_date": self.effective_date, + "sha256": self.sha256, + } + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _dataset_dir() -> str: + return os.path.join(_project_root(), *_RESOURCE_SUBPATH) + + +def _manifest_entry(file_name: str) -> dict[str, Any]: + manifest_path = os.path.join(_dataset_dir(), "_manifest.json") + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + for entry in manifest.get("files", []): + if entry.get("file") == file_name: + return entry + raise RateLookupError(f"매니페스트에 없는 요율 파일입니다: {file_name}") + + +@lru_cache(maxsize=8) +def load_rate_dataset(file_name: str = "rates_2026.json") -> RateDataset: + """요율 파일 한 벌을 읽는다. 매니페스트의 지문·적용일을 함께 실어 재현성을 남긴다.""" + entry = _manifest_entry(file_name) + with open(os.path.join(_dataset_dir(), file_name), encoding="utf-8") as handle: + payload = json.load(handle) + return RateDataset( + dataset_id=payload.get("dataset_id", entry.get("dataset_id", "")), + effective_date=payload.get("effective_date", entry.get("effective_date", "")), + sha256=entry.get("sha256", ""), + variables=payload.get("variables", {}), + ) + + +@lru_cache(maxsize=8) +def load_rate_dataset_from_path(path: str) -> RateDataset: + """매니페스트 밖의 요율 파일을 읽는다 — **옛 연도 재현 검산 전용**. + + 정본 요율은 `load_rate_dataset` 으로만 읽는다. 이 함수는 「2024년 값으로 돌리면 + 그때 서류가 재현되는가」를 시험하려고 두는 것이고, 지문이 없으므로 결과에 + `sha256=""` 로 남아 **정본이 아님이 드러난다**. + """ + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + return RateDataset( + dataset_id=payload.get("dataset_id", ""), + effective_date=payload.get("effective_date", ""), + sha256="", + variables=payload.get("variables", {}), + ) + + +def _bracket_bounds(label: str) -> tuple[Decimal, Decimal] | None: + """금액 구간 라벨 → [하한, 상한). 숫자 구간이 아니면 None.""" + match = _RE_LT.match(label) + if match: + return Decimal(0), Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)] + + match = _RE_RANGE_TWO_UNIT.match(label) + if match: + low = Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)] + high = Decimal(match.group(3)) * _UNIT_MULTIPLIER[match.group(4)] + return low, high + + match = _RE_RANGE_ONE_UNIT.match(label) + if match: + unit = _UNIT_MULTIPLIER[match.group(3)] + return Decimal(match.group(1)) * unit, Decimal(match.group(2)) * unit + + match = _RE_GTE.match(label) + if match: + return Decimal(match.group(1)) * _UNIT_MULTIPLIER[match.group(2)], Decimal("Infinity") + + return None + + +def _duration_bounds(label: str) -> tuple[int, int] | None: + """공사기간 구간 라벨 → [하한일, 상한일]. 숫자 구간이 아니면 None.""" + match = _RE_DAYS_LTE.match(label) + if match: + return 0, int(match.group(1)) + + match = _RE_DAYS_RANGE.match(label) + if match: + return int(match.group(1)), int(match.group(2)) + + match = _RE_DAYS_GTE.match(label) + if match: + return int(match.group(1)), 10**9 + + return None + + +def _amount_matches(label: str, amount: Decimal) -> bool: + bounds = _bracket_bounds(label) + if bounds is None: + return False + low, high = bounds + return low <= amount < high + + +def _duration_matches(label: str, days: int) -> bool: + bounds = _duration_bounds(label) + if bounds is None: + return False + low, high = bounds + return low <= days <= high + + +def select_bracket( + brackets: list[dict[str, Any]], + *, + amount_field: str | None = None, + amount: Decimal | None = None, + duration_days: int | None = None, + duration_field: str = "duration_bracket", + equals: dict[str, Any] | None = None, + residual_label: str | None = None, + prefer_suffix: str | None = None, + label: str, +) -> dict[str, Any]: + """구간 목록에서 한 행을 고른다. 못 고르면 `RateLookupError` — 기본값으로 안 때운다. + + `equals` 는 `work_type` 처럼 값이 그대로 맞아야 하는 열이다. + `residual_label` 은 숫자 구간이 아닌 **잔여 구간** 라벨이다(예: 고용보험료의 + `below_official_threshold`). 숫자 구간이 하나도 안 맞을 때만 쓰며, **부르는 쪽이 + 이름을 대야** 한다 — 조용한 기본값이 아니다. + """ + candidates = list(brackets) + + if equals: + for key, expected in equals.items(): + candidates = [row for row in candidates if row.get(key) == expected] + + if amount_field is not None and amount is not None: + candidates = [ + row for row in candidates if _amount_matches(str(row.get(amount_field, "")), amount) + ] + + if duration_days is not None: + candidates = [ + row + for row in candidates + if _duration_matches(str(row.get(duration_field, "")), duration_days) + ] + + if not candidates and residual_label is not None and amount_field is not None: + candidates = [row for row in brackets if row.get(amount_field) == residual_label] + if equals: + for key, expected in equals.items(): + candidates = [row for row in candidates if row.get(key) == expected] + + if len(candidates) > 1 and prefer_suffix is not None and amount_field is not None: + # 같은 금액 구간이 공종으로 갈리는 표가 있다(하도급보증의 `…_integrated_civil…`). + # 부르는 쪽이 공종을 대야 하며, 조용한 기본값이 아니다. + narrowed = [r for r in candidates if str(r.get(amount_field, "")).endswith(prefer_suffix)] + if narrowed: + candidates = narrowed + + if not candidates: + raise RateLookupError( + f"{label}: 조건에 맞는 요율 구간이 없습니다 " + f"(금액={amount}, 기간={duration_days}일, 조건={equals})" + ) + if len(candidates) > 1: + raise RateLookupError( + f"{label}: 요율 구간이 {len(candidates)}개 겹칩니다 — 데이터 점검 필요 " + f"({[row.get(amount_field) for row in candidates]})" + ) + return candidates[0] + + +def rate_percent(row: dict[str, Any], *, label: str) -> Decimal: + if "rate_percent" not in row: + raise RateLookupError(f"{label}: 고른 구간에 요율이 없습니다 ({row})") + return Decimal(str(row["rate_percent"])) + + +def base_amount(row: dict[str, Any]) -> Decimal: + """구간에 딸린 기초액(안전관리비 등). 없으면 0.""" + return Decimal(str(row.get("base_amount_krw", 0))) + + +def flat_rate(dataset: RateDataset, name: str) -> Decimal: + """구간이 없는 단일 요율(산재·건강·요양·부가세 등).""" + variable = dataset.variable(name) + if "rate_percent" not in variable: + raise RateLookupError(f"{name}: 단일 요율이 아닙니다 — 구간 조회가 필요합니다") + return Decimal(str(variable["rate_percent"])) + + +def pension_rate_percent(dataset: RateDataset, year: int) -> Decimal: + """국민연금 — 연도별 특례 스케줄(2026 = 4.75 %, 2033~ 본칙 6.5 %).""" + variable = dataset.variable("rate_pension") + for row in variable.get("annual_rates", []): + if int(row.get("year", 0)) == year: + return Decimal(str(row["rate_percent"])) + fallback = variable.get("rate_from_2033_percent") + if fallback is None: + raise RateLookupError(f"rate_pension: {year}년 요율이 없습니다") + return Decimal(str(fallback)) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py new file mode 100644 index 00000000..2455f881 --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -0,0 +1,688 @@ +"""B09 원가계산 — 자원 축 매칭 (PLAN 8-6 · 9-3). + +메인 창이 낸 **공종 축**(`resources/data_work_item_master/`)을 **읽기만** 하고, 그 위에 +**자원 축**(`resource_kind`·`resource_code`·`resource_spec`·`amount`·`amount_unit`)을 +붙여 **별도 파일**로 낸다. 원본은 고치지 않는다 — 메인이 품셈을 다시 돌리면 덮이므로 +그 안에 섞으면 사라진다. + +지켜야 할 것 + 1. **`pum_form` 을 먼저 본다.** `productivity`(생산량형) = **1 ÷ 값**, + `requirement`(소요량형) = **값 ÷ basis_quantity**. ⚠ **뒤집으면 20배 틀린다.** + 직종 이름부터 보면 「작업능력(㎥/hr)」 표의 비고란 「보통인부 1인/일」에 끌려 + 생산량형을 소요량형으로 읽는다(메인이 실제로 한 번 뒤집혔다가 잡은 자리). + 2. **`coefficient` · `reference` 는 공종이 아니다.** 일위대가 항목으로 세우지 않는다. + `undetermined` 는 **값을 쓰지 않는다.** + 3. **규격(`resource_spec`)이 없으면 매칭 성공으로 치지 않는다.** 「굴착기」와 + 「굴착기 0.7㎥」는 단가가 다르다 — 이름만 맞추면 조용히 틀린 단가가 붙는다. + 4. **못 맞춘 것은 빈칸이 아니라 `unmatched` 목록**으로 낸다. + 5. **자재는 할증 전 값**이다 (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳뿐이다. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from typing import Any + +#: 자원 축을 붙일 수 있는 표 형태. 나머지는 값을 쓰지 않는다. +USABLE_FORMS = frozenset({"productivity", "requirement"}) +#: 공종이 아닌 표 — 일위대가 항목으로 세우지 않는다. +NON_WORK_ITEM_FORMS = frozenset({"coefficient", "reference"}) +#: 형태 판정이 안 된 표 — 값을 쓰지 않는다. +UNUSABLE_FORMS = frozenset({"undetermined"}) + +_MASTER_SUBPATH = ("resources", "data_work_item_master") +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") + +#: 규격이 이름 안에 붙어 있는 흔한 모양 — 「굴착기(0.7㎥)」·「덤프트럭 15톤」. +_RE_SPEC = re.compile(r"[((]([^))]+)[))]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))") +_RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$") + +#: 첫 칸이 **분류 딱지**이고 이름이 둘째 칸에 오는 표가 있다. +#: 예 — `['자재', '종 자', '', 'kg', '0.025']` · `['장비', '종자살포기', …]`. +#: 이 표를 첫 칸만 보고 읽으면 **자재·장비가 통째로 빠진다**(2026-09-07 실측 — +#: 씨앗뿜어붙이기에서 종자·비료·피복제·침식안정제·색소·장비 3종이 다 빠지고 +#: 보통인부 한 줄만 남았다). +_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계") + +#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다. +#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다. +#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`· +#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이 +#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음). +#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다. +_NON_RESOURCE_WORDS = ( + "구분", + "합계", + "소계", + "계", + "단위", + "비고", + "규격", + "명칭", + "품명", + "종류", + "항목", + "적용", + "기준", + "산출", + "비율", + "할증", + "할인", + "직접노무비", + "재료비", + "경비", + "위치", + "면적", + "수량", + "공종", + "작업", + "내역", + "총계", + "인력", + "장비", + "기계", +) + + +class ResourceAxisError(ValueError): + """자원 축을 붙일 수 없는 경우. 조용히 넘기지 않는다.""" + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _read_json(*parts: str) -> dict[str, Any]: + with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle: + return json.load(handle) + + +@dataclass(frozen=True) +class CatalogEntry: + """단가 카탈로그 한 줄 — 매칭 대상.""" + + code: str + name: str + kind: str + spec: str = "" + + +@dataclass +class ResourceCatalog: + """이름 → 코드. **같은 이름에 규격이 여럿이면 규격 없이는 못 고른다.**""" + + entries: list[CatalogEntry] = field(default_factory=list) + aliases: dict[str, str] = field(default_factory=dict) + #: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다. + _index: dict[str, list[CatalogEntry]] | None = None + + def by_name(self, name: str) -> list[CatalogEntry]: + if self._index is None: + index: dict[str, list[CatalogEntry]] = {} + for entry in self.entries: + index.setdefault(_normalize(entry.name), []).append(entry) + self._index = index + return self._index.get(_normalize(name), []) + + def resolve(self, name: str, spec: str) -> CatalogEntry | None: + """이름(+규격)으로 한 줄을 고른다. 못 고르면 None — 0 으로 안 때운다.""" + found = self.by_name(name) + if not found: + return None + if len(found) == 1: + return found[0] + # 이름이 여럿이면 규격이 있어야 고를 수 있다. + if not spec: + return None + narrowed = [e for e in found if _normalize(e.spec) == _normalize(spec)] + return narrowed[0] if len(narrowed) == 1 else None + + +#: 첫 칸이 자원 이름이 **아닌** 표가 많다 — 규격 구간표(「10∼12」), 기호표(「f」·「E」), +#: 치수표 등. 그런 셀을 못 맞춘 목록에 넣으면 목록이 못 쓰게 되므로 먼저 거른다. +#: ⚠ **물결표·붙임표 목록은 여기 한 벌뿐이다.** 네 파일에 따로 적어 두었더니 서로 +#: 달라졌다(2026-09-08 메인 창 교차검토 — 어떤 목록엔 `~`, 어떤 목록엔 `〜` 가 빠졌음). +#: 지금 물리는 것은 없었으나 **같은 목록이 네 벌이면 언젠가 하나만 고쳐진다.** +#: 갈래 키 정규화(`B09_Estimation_UnitPrice.normalize_variant_key`)도 이 목록을 쓴다. +RANGE_DASHES = "∼~〜~-–‐" + +#: ⚠ **문자클래스에 그대로 넣지 말 것** — `~-–` 이 **범위 연산자**로 읽혀 거의 모든 +#: 글자가 걸린다(2026-09-08 실측: 「0.7㎥」·「15톤」이 구간으로 잡혔음). 반드시 이 쪽을 쓴다. +RANGE_DASH_CLASS = "".join(re.escape(ch) for ch in RANGE_DASHES) + +#: 구간 셀에는 **단위 꼬리**가 붙기도 한다 — 「51~100m」·「12~14㎝」(2026-09-08 실측 39줄). +#: ⚠ **단위가 붙었다고 다 구간이 아니다** — 「굴착기 0.7㎥」는 규격이고 자원 이름의 일부다. +#: 그래서 **수 ~ 수 + 단위**라는 모양 전체가 맞을 때만 구간으로 본다(앞에 이름이 없어야 한다). +_RANGE_UNITS = "a-zA-Z㎝㎜㎥㎡㎞mm톤" +_RE_RANGE_CELL = re.compile( + rf"^\d+(?:\.\d+)?\s*[{RANGE_DASH_CLASS}]\s*\d+(?:\.\d+)?\s*[{_RANGE_UNITS}]+$" +) +_RE_HANGUL = re.compile(r"[가-힣]") + + +def is_non_resource_label(cell: str) -> bool: + """표 머리글·소계 행이거나, 애초에 자원 이름이 올 자리가 아닌 셀인가. + + 못 맞춘 목록에 이런 것이 섞이면 목록 자체가 못 쓰게 된다. 여기서 먼저 걷어낸다. + """ + text = _normalize(cell) + if not text: + return True + if text.startswith(("※", "<", "(", "-", "ㆍ", "·")): + return True + if _RE_RANGE_CELL.match(text): # 규격 구간표의 첫 칸 + return True + # 자원 이름은 숫자로 시작하지 않는다 — 「50이상」·「100m이하」·「2.집재」는 구간·절번호다. + if text[0].isdigit(): + return True + # 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다. + if len(_RE_HANGUL.findall(text)) < 2: + return True + # ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석). + if text in _NON_RESOURCE_WORDS: + return True + # 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다. + return _is_header_composite(text) + + +def _is_header_composite(text: str) -> bool: + """머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것. + + 낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면 + 반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`). + """ + rest = text + for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True): + rest = rest.replace(word, "") + rest = rest.replace("및", "").replace("별", "").strip() + return rest == "" + + +def _normalize(text: str) -> str: + """표 셀의 공백·개행 흔들림을 지운다. 「경 암」·「연 암」 같은 것.""" + return re.sub(r"\s+", "", str(text or "")).strip() + + +@dataclass +class ResourceRow: + """자원 축 한 줄 — 공종(표) 하나에 붙는 자원 하나.""" + + work_item_code: str + pum_table_id: str + pum_form: str + resource_kind: str + resource_code: str + resource_name: str + resource_spec: str + amount: Decimal + amount_unit: str + raw_row_index: int + #: 조건 시공 시 쓰는 대안값 — 「1.04(1.17)」의 1.17 (품셈 13-6-1 [주]②). + #: **기본값은 `amount`(괄호 밖)** 이고 이 값은 화면에 「시공 시 다름」으로 보인다. + alternative_amount: Decimal | None = None + #: 규격 갈래 — 열이 자원인 표에서 행 이름(「무근구조물」). 없으면 빈 문자열. + #: **갈래마다 품이 다르므로 한 일위대가로 뭉치지 않는다.** + variant: str = "" + #: 분류 딱지가 달고 온 배분율 — 「인력(10%)」이면 `10`. 없으면 `None`. + #: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다 + #: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다). + group_ratio_pct: Decimal | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "work_item_code": self.work_item_code, + "pum_table_id": self.pum_table_id, + "pum_form": self.pum_form, + "resource_kind": self.resource_kind, + "resource_code": self.resource_code, + "resource_name": self.resource_name, + "resource_spec": self.resource_spec, + "amount": str(self.amount), + "amount_unit": self.amount_unit, + "raw_row_index": self.raw_row_index, + "variant": self.variant, + "alternative_amount": ( + None if self.alternative_amount is None else str(self.alternative_amount) + ), + "group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct), + } + + +@dataclass +class UnmatchedRow: + """못 맞춘 것 — **빈칸으로 두지 않고 여기 모은다**.""" + + work_item_code: str + pum_table_id: str + cell: str + reason: str + + def as_dict(self) -> dict[str, Any]: + return { + "work_item_code": self.work_item_code, + "pum_table_id": self.pum_table_id, + "cell": self.cell, + "reason": self.reason, + } + + +@dataclass +class AxisResult: + rows: list[ResourceRow] = field(default_factory=list) + unmatched: list[UnmatchedRow] = field(default_factory=list) + skipped_forms: dict[str, int] = field(default_factory=dict) + #: 제잡비 비율(%) — `{공종코드: (윗단, 아랫단)}`. 윗단은 물빼기 파이프 설치, + #: 아랫단은 미설치 (품셈 13-6-2 [주]③). 값이 하나뿐이면 둘이 같다. + overhead_ratio: dict = field(default_factory=dict) + #: 자원은 알아봤는데 **값을 못 읽은** 줄이 있는 공종 — 그 단가는 「일부만 선 것」이다. + #: 기초잡석 12-25 가 `소할(30%) | 할석공(인) | 0.2 × 30%` 를 못 읽어 부설다짐만으로 + #: 107,145 원이 서고 있었다(2026-09-08). **부분 성공이 가장 위험하다.** + partial_items: dict[str, str] = field(default_factory=dict) + + +def _tidy_resource_name(cell: str) -> str: + """이름 표기를 카탈로그 쪽으로 맞춘다 — **뜻을 바꾸지 않는 표기 차이만.** + + ① 이름 안 공백 제거 (「굴 삭 기 (무한궤도)」 → 「굴삭기(무한궤도)」) + ② 같은 기종의 다른 이름 (「굴삭기」·「유압식백호우」 → 「굴착기」) + + ⚠ 규격은 안 건드린다 — 규격을 맞추려 들면 엉뚱한 기종이 붙는다. + """ + from B09_Estimation.B09_Estimation_MachineProductivity import MACHINE_NAME_ALIASES + + text = str(cell) + head, sep, tail = text.partition("(") + tight = "".join(head.split()) + for wrong, right in MACHINE_NAME_ALIASES.items(): + if tight == wrong: + tight = right + break + return tight + sep + tail + + +#: 장비 줄의 단위 — 시간·대수로 센다. 자재는 kg·매·㎥ 로 센다. +_MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간") + + +def _is_machine_like_row(cells: list[str]) -> bool: + """그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다.""" + for cell in cells: + text = _normalize(cell) + if text and any(text == unit or text.replace(" ", "") == unit for unit in _MACHINE_UNITS): + return True + return False + + +def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]): + """셀 하나를 카탈로그 한 줄로 푼다 — 세 가지 모양을 차례로 시도한다. + + ① 셀 전체가 곧 이름 (「보통인부」) + ② 기종 셀 (「굴착기(무한궤도, 0.7㎥)」 → 이름 + 규격) + ③ 규격이 **옆 칸**에 있는 표 (「굴착기 (무한궤도)」 | 「0.7㎥」) + """ + # ⚠ **이름 안 공백·표기 차이를 먼저 없앤다.** 품셈은 같은 기종을 「굴 삭 기」· + # 「굴착기」·「유압식백호우」로 섞어 적는다(2026-09-08: 메쌓기 13-6-1 의 + # 「굴 삭 기 (무한궤도)」가 안 붙어 그 공종 장비 몫이 통째로 빠졌다). + name_cell = _tidy_resource_name(name_cell) + machine_name, machine_spec = parse_machine_cell(name_cell) + plain_name, plain_spec = split_name_and_spec(name_cell) + + for name, spec in ((machine_name, machine_spec), (plain_name, plain_spec)): + if not name: + continue + entry = catalog.resolve(name, spec) + if entry is not None: + return entry + + # 이름은 맞는데 규격이 없어 못 고른 경우 — 옆 칸에서 규격을 찾는다. + for name in (machine_name, plain_name): + if len(catalog.by_name(name)) <= 1: + continue + for candidate in spec_candidates(cells[1:]): + entry = catalog.resolve(name, candidate) + if entry is not None: + return entry + return None + + +def match_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> None: + """표 하나에 자원 축을 붙인다. 값이 안 서면 `unmatched` 로 보낸다.""" + # 예시 서식(「ha당 …단가산출서(예시)」)은 **B08 이 마스터 원천에서 거른다**(8건). + # 두 곳에서 같은 것을 거르면 **나중에 한쪽만 고쳐진다** — 원천이 이긴다. + # ⚠ 짝 시험은 남겨 둔다(예시 서식이 자원으로 안 서는지) — 원천이 바뀌면 그것이 알려 준다. + + from B09_Estimation.B09_Estimation_CrewOutput import match_crew_table + + # ⚠ **작업조 표는 형태 판정보다 먼저 가른다.** 「형틀목공 4인 / 시공량 35㎡」 표는 + # 마스터에서 `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(유로폼 12-38-3). + # 그 표는 모양이 스스로를 말한다 — 시공량 열 + 작업조 줄이 있으면 그것이다. + # 행-자원으로 읽으면 **인원 4를 소요량 4로** 오해해 35배 부푼다. + if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""): + return + + form = table.get("pum_form", "") + if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS: + result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1 + return + + basis = table.get("basis_quantity") + basis_quantity = None if basis is None else Decimal(str(basis)) + unit = table.get("basis_unit") or "" + + # ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열). + # 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로 + # 달고 있었다(3.8 배 차이). 형태 판정보다 먼저 가른다. + from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import match_unit_basis_table + + if match_unit_basis_table(node, table, catalog, result): + return + + # ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표). + # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 + # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — + # 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다. + from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_transposed_table + + if match_transposed_table(node, table, catalog, result, basis_quantity, unit): + return + + # 「석공 보통인부 | 0.09 0.05 | …」처럼 **이름도 값도 뭉쳐 오고 열이 갈래**인 표 + # (돌쌓기 13-4 계열). 행-자원으로는 첫 이름조차 안 풀린다. + from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_packed_rows + + if match_packed_rows(node, table, catalog, result, basis_quantity, unit): + return + + for index, row in enumerate(table.get("raw_row", [])): + cells = [str(c) for c in row] + if not cells: + continue + # 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다. + # + # ⚠ 딱지 목록만으로는 모자란다 — 첫 칸이 **공정 이름**인 표가 따로 있다 + # (기초잡석 12-25: `소할(30%) | 할석공(인) | 0.2 × 30%`). 목록에 없는 말이라 + # 통째로 못 맞추고 있었다. 그래서 **딱지 목록에 없더라도 첫 칸이 자원으로 안 풀리고 + # 둘째 칸이 풀리면** 이름을 둘째 칸에서 읽는다 — 판정을 낱말이 아니라 + # **풀리는지**로 한다. 배분율 꼬리표(「(30%)」)는 어느 쪽이든 첫 칸에서 읽는다. + name_cell = cells[0] + value_cells = cells[1:] + group_ratio = None + if len(cells) > 1 and ( + _group_label_of(name_cell) is not None + or ( + _resolve_cell(catalog, name_cell, cells) is None + and _resolve_cell(catalog, cells[1], cells[1:]) is not None + ) + ): + group_ratio = _group_ratio_of(name_cell) + name_cell = cells[1] + value_cells = cells[2:] + + # 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③). + if "제잡비" in _normalize(name_cell): + ratios = [ + parse_amount(_normalize(token)) + for cell in value_cells + for token in _RE_ALTERNATIVE.sub(r"", _normalize(cell)).split(" ") + ] + ratios = [x for x in ratios if x is not None] + if ratios: + upper = ratios[0] + lower = ratios[1] if len(ratios) > 1 else ratios[0] + result.overhead_ratio[node["work_item_code"]] = (upper, lower) + continue + + # ⚠ **공식 계수를 단 줄은 자원 줄이 아니다.** 「유압식백호우 … | k | 0.9」 처럼 + # 같은 줄에 버킷계수가 붙어 오는데, 그 0.9 를 소요량으로 읽으면 **시간당 사용료가 + # 0.9시간분** 붙어 이중이 된다(2026-09-08: 이름 표기를 맞추자 측구터파기에 + # 「굴착기 0.81」 줄이 새로 생겨 발견). 그 줄은 시공능력 공식 쪽에서 쓴다. + if any(_normalize(c).lower() in ("k", "f", "e") for c in value_cells): + continue + + # 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다. + alternative: Decimal | None = None + amount_cell = None + for cell in value_cells: + pair = parse_amount_pair(cell) + if pair is not None: + amount_cell, alternative = pair + break + if amount_cell is None: + # 「0.2 × 30%」 꼴은 값과 배분율이 한 칸에 있다 — 읽었으면 딱지 배분율은 버린다. + expression = next( + ( + parse_amount_expression(c) + for c in value_cells + if parse_amount_expression(c) is not None + ), + None, + ) + if expression is not None: + amount_cell = expression + group_ratio = None # ⚠ 이미 값 안에 들어 있다 — 또 곱하면 두 번이다 + if amount_cell is None: + # ⚠ **이름은 자원인데 값을 못 읽은 줄**은 다르다 — 그 공종 단가는 성분이 + # 빠진 채 서게 된다. 조용히 넘기지 않고 「일부만 섬」으로 표시한다. + # ⚠ **숫자가 아예 없는 줄은 머리 줄**이다 — 자원 이름만 나열된 줄 + # (「특별인부 | 벌목부 | 보통인부」). 그것까지 「못 읽은 값」으로 세면 + # 정상 공종이 무더기로 막힌다(2026-09-08: 28건 중 대부분이 이 오탐이었다). + # 숫자가 **있는데** 못 읽은 줄만 성분 빠짐으로 본다. + has_digit = any(ch.isdigit() for cell in value_cells for ch in cell) + if ( + has_digit + and _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) is not None + ): + result.partial_items[node.get("work_item_code", "")] = ( + f"{name_cell} 줄의 값을 못 읽었습니다" + ) + result.unmatched.append( + UnmatchedRow( + work_item_code=node.get("work_item_code", ""), + pum_table_id=str(table.get("pum_table_id", "")), + cell=" | ".join(cells[:3]), + reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + ) + ) + continue + + # ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때 + # 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을 + # 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다. + entry = _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) + if entry is None: + if is_non_resource_label(name_cell): + continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다 + name, spec = split_name_and_spec(name_cell) + found = catalog.by_name(name) + if len(found) > 1: + reason = "규격이 없어 같은 이름 여럿 중 고를 수 없음" + else: + # 지금 가진 카탈로그는 노임뿐이다. 기계·자재는 카탈로그 자체가 없어 + # 못 맞추는 것이므로 사유를 갈라 적는다 — 「이름이 틀림」과 다르다. + reason = "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)" + result.unmatched.append( + UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason) + ) + # ⚠ **시간·대수로 세는 줄은 장비 몫**이다 — 못 맞추면 그 공종 단가가 + # 노무만으로 서서 조용히 싸진다(2026-09-08: 초본류 시비가 「트럭(2.5t) + # 2.6시간」을 빼고 33.1원/㎡ 로 섰다). 자재 줄(kg·매)은 이미 알려진 + # 미결이라 막지 않고 드러내기만 한다. + if _is_machine_like_row(value_cells): + result.partial_items[node["work_item_code"]] = ( + f"{_normalize(name_cell)[:20]} (장비 줄)을 못 맞췄습니다" + ) + continue + + try: + amount = convert_amount(amount_cell, pum_form=form, basis_quantity=basis_quantity) + except ResourceAxisError as error: + result.unmatched.append( + UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, str(error)) + ) + continue + + result.rows.append( + ResourceRow( + work_item_code=node["work_item_code"], + pum_table_id=table["pum_table_id"], + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + group_ratio_pct=group_ratio, + alternative_amount=alternative, + ) + ) + + +#: 분류 딱지가 **비율을 달고 오는** 모양 — 「인력(10%)」·「장비(90%)」. +#: 2026-09-08 실측: 측구터파기(FP-09-12-01) 표가 이 모양이라 딱지 판정이 빗나가 +#: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다). +#: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는 +#: 떼면 안 되므로 넓게 잡지 않는다. +#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값. +#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」 +#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]). +#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기). +#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다. +_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[((](\d+(?:\.\d+)?)[))]$") + + +_RE_RATIO_SUFFIX = re.compile(r"[((][\d.\s]*%?[))]$") + + +def _group_label_of(cell: str) -> str | None: + """첫 칸이 분류 딱지면 그 딱지를, 아니면 `None` 을 돌려준다. + + ⚠ 딱지 목록은 **정확 일치**를 유지한다(부분일치가 정상 자원을 지운 전례 — + `_NON_RESOURCE_WORDS` 주석). 비율 꼬리표만 떼고 다시 정확 일치로 본다. + """ + text = _normalize(cell) + if text in _GROUP_LABELS: + return text + stripped = _normalize(_RE_RATIO_SUFFIX.sub("", text)) + return stripped if stripped in _GROUP_LABELS else None + + +def _group_ratio_of(cell: str) -> Decimal | None: + """분류 딱지에 붙은 배분율. 「인력(10%)」 → `10`, 「자재」 → `None`.""" + found = _RE_RATIO_SUFFIX.search(_normalize(cell)) + if found is None: + return None + digits = found.group(0).strip("()()%").strip() + return Decimal(digits) if digits else None + + +def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult: + """공종 축 전체를 훑어 자원 축을 만든다.""" + result = AxisResult() + for node in master.get("work_items", []): + for table in node.get("tables", []): + match_table(node, table, catalog, result) + return result + + +#: 자원 축 산출물이 나가는 자리 — **메인의 `data_work_item_master/` 안에 넣지 않는다.** +#: 메인이 품셈을 다시 돌리면 그 폴더가 덮이므로 섞으면 사라진다. +OUTPUT_SUBPATH = ("resources", "data_cost_resource_axis") + + +def _master_file_fingerprint(master: dict[str, Any]) -> dict[str, str]: + """공종 마스터 **파일 자체**의 지문. 낡은 파생물을 드러내는 유일한 근거다. + + `dataset_version`(품셈 원판 지문)은 마스터가 다시 생성돼도 그대로라, 그것만 + 적어 두면 「내 자원 축이 옛 마스터에서 나왔다」는 사실이 안 보인다. + """ + effective_date = master.get("effective_date", "") + file_name = f"work_item_master_{effective_date}.json" + path = os.path.join(_project_root(), *_MASTER_SUBPATH, file_name) + try: + with open(path, "rb") as handle: + digest = hashlib.sha256(handle.read()).hexdigest() + except OSError: + return {"file": file_name, "sha256": ""} + return {"file": file_name, "sha256": digest} + + +def write_resource_axis( + result: AxisResult, + master: dict[str, Any], + *, + output_dir: str | None = None, +) -> dict[str, str]: + """자원 축과 못 맞춘 목록을 파일로 낸다. 만든 파일 경로를 돌려준다.""" + directory = output_dir or os.path.join(_project_root(), *OUTPUT_SUBPATH) + os.makedirs(directory, exist_ok=True) + effective_date = master.get("effective_date", "") + + axis_path = os.path.join(directory, f"resource_axis_{effective_date}.json") + unmatched_path = os.path.join(directory, f"unmatched_{effective_date}.json") + + axis_payload = { + "schema_version": "1.0", + "dataset_id": "resource_axis_forest", + "effective_date": effective_date, + # 어느 공종 축 판에 붙인 것인지 — 세 쪽을 그대로 옮겨 적는다(PLAN 9-2). + "source_dataset_version": master.get("dataset_version", {}), + # ⚠ 위 지문은 **품셈 원판**의 것이라 B08 이 마스터를 다시 생성해도 안 움직인다. + # 낡음을 실제로 드러내려면 **마스터 파일 자체의 지문**이 있어야 한다. + "source_master_file": _master_file_fingerprint(master), + "policy": { + "axis": "resource_only", + "work_item_axis_owner": "B08", + "material_amounts_are_before_surcharge": True, + }, + "stats": { + "rows": len(result.rows), + "unmatched": len(result.unmatched), + "skipped_forms": result.skipped_forms, + }, + "rows": [r.as_dict() for r in result.rows], + } + unmatched_payload = { + "schema_version": "1.0", + "effective_date": effective_date, + "note": ( + "못 맞춘 자원 이름. 빈칸으로 두지 않고 여기 모은다. " + "기계·자재 카탈로그가 아직 없어 그 계열은 전부 여기로 온다." + ), + "rows": [u.as_dict() for u in result.unmatched], + } + + for path, payload in ((axis_path, axis_payload), (unmatched_path, unmatched_payload)): + with open(path, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + + return {"resource_axis": axis_path, "unmatched": unmatched_path} + + +# 카탈로그 적재·셀 파싱은 700줄 제한으로 `_Sources` 파일로 옮겼다. +# 가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. +from B09_Estimation.B09_Estimation_ResourceAxis_Sources import ( # noqa: E402 + convert_amount, + load_combined_catalog, + load_labor_catalog, + load_machine_catalog_entries, + load_material_catalog_entries, + load_work_item_master, + parse_amount, + parse_amount_expression, + parse_amount_pair, + parse_machine_cell, + spec_candidates, + split_name_and_spec, +) diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py new file mode 100644 index 00000000..69f3c23f --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Sources.py @@ -0,0 +1,220 @@ +"""B09 원가계산 — 자원 축 **자료 적재·셀 파싱** (`B09_Estimation_ResourceAxis` 보조). + +가르는 금은 「자료를 읽어 들이는가 / 표를 자원 축으로 접는가」다. 700줄 제한(CLAUDE.md +4장)에 걸려 나눴고, 부르는 쪽은 종전대로 `B09_Estimation_ResourceAxis` 에서 가져다 쓴다. + +⚠ **셀을 억지로 읽지 않는다** — 범위(「0.55∼0.45」)·참조(「육상과동일」)·반복부호(「〃」)는 +확정값이 아니므로 `None` 을 돌려주고, 그 사실이 위쪽에서 드러난다. +""" + +from __future__ import annotations + +import json +import os +import re +from decimal import Decimal, InvalidOperation +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + CatalogEntry, + RANGE_DASHES, + ResourceAxisError, + ResourceCatalog, + _normalize, + _project_root, + _read_json, + _RE_NUMBER, + _RE_RANGE_CELL, + _RE_SPEC, + _RE_ALTERNATIVE, +) + +_CATALOG_SUBPATH = ("resources", "data_cost_input_value") +_MASTER_SUBPATH = ("resources", "data_work_item_master") + + +def load_labor_catalog(file_name: str = "labor_const_2026-01-01.json") -> ResourceCatalog: + """노임 카탈로그 132직종 + `aliases`. 코드는 `occupation_code`.""" + payload = _read_json(*_CATALOG_SUBPATH, file_name) + variables = payload["variables"] + records = variables["labor_rate"]["records"] + entries = [ + CatalogEntry(code=str(r["occupation_code"]), name=r["occupation_name"], kind="labor") + for r in records + ] + return ResourceCatalog(entries=entries, aliases=dict(variables.get("aliases", {}))) + + +def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list[CatalogEntry]: + """기종 카탈로그 613건을 매칭용 항목으로 편다. + + ⚠ **규격이 매칭의 일부**다 — 「굴착기(무한궤도)」만 23 규격이라 이름만으로는 + 한 대가 안 정해진다(지시 4번). `CatalogEntry.spec` 에 규격을 실어 둔다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog(file_name) + return [ + CatalogEntry(code=m.machine_code, name=m.name, kind="machine", spec=m.specification) + for m in catalog.machines.values() + ] + + +def load_material_catalog_entries( + file_name: str = "mat_price_public_2026-08-14.json", +) -> list[CatalogEntry]: + """관급 자재 6,999건을 매칭용 항목으로 편다. + + ⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323). + **규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog + + catalog = load_material_catalog(file_name) + return [ + CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification) + for m in catalog.items.values() + ] + + +def load_combined_catalog() -> ResourceCatalog: + """노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다.""" + labor = load_labor_catalog() + return ResourceCatalog( + entries=[ + *labor.entries, + *load_machine_catalog_entries(), + *load_material_catalog_entries(), + ], + aliases=labor.aliases, + ) + + +def load_work_item_master( + file_name: str = "work_item_master_2026-01-01.json", +) -> dict[str, Any]: + """메인 창 산출물 — **읽기 전용**.""" + return _read_json(*_MASTER_SUBPATH, file_name) + + +def split_name_and_spec(cell: str) -> tuple[str, str]: + """셀 문자열에서 이름과 규격을 가른다. + + 「유압식백호우 (무한궤도,0.7㎥)」 → (`유압식백호우`, `무한궤도,0.7㎥`) + 「덤프트럭 15톤」 → (`덤프트럭`, `15톤`) + """ + text = str(cell or "").strip() + match = _RE_SPEC.search(text) + if not match: + return text, "" + spec = (match.group(1) or match.group(2) or "").strip() + name = (text[: match.start()] + text[match.end() :]).strip(" ,()()") + return name, spec + + +#: 기종 이름의 괄호 안은 **규격과 형식이 섞여** 있다 — +#: 「굴착기(무한궤도, 0.7㎥)」 는 이름 `굴착기(무한궤도)` + 규격 `0.7` 이다. +#: 형식(무한궤도·타이어)은 이름의 일부이고, 숫자가 든 조각만 규격이다. +_RE_MACHINE = re.compile(r"^(?P[^()()]+)[((](?P[^))]*)[))]") +_RE_SIZE_TOKEN = re.compile(r"\d+(?:\.\d+)?") + + +def parse_machine_cell(cell: str) -> tuple[str, str]: + """기종 셀을 카탈로그 이름과 규격으로 가른다. + + 「굴착기(무한궤도, 0.7㎥)」 → (`굴착기(무한궤도)`, `0.7`) + 「굴착기 (무한궤도)」 → (`굴착기(무한궤도)`, ``) ← 규격은 옆 칸에 있다 + 괄호가 없으면 원문 그대로 돌려준다. + """ + text = _normalize(cell) + match = _RE_MACHINE.match(text) + if not match: + return text, "" + base = match.group("base") + parts = [p for p in re.split(r"[,·/]", match.group("inner")) if p] + form_parts = [p for p in parts if not _RE_SIZE_TOKEN.search(p)] + size_parts = [p for p in parts if _RE_SIZE_TOKEN.search(p)] + name = f"{base}({','.join(form_parts)})" if form_parts else base + spec = "" + if size_parts: + found = _RE_SIZE_TOKEN.search(size_parts[0]) + spec = found.group(0) if found else "" + return name, spec + + +def spec_candidates(cells: list[str]) -> list[str]: + """규격이 옆 칸에 있는 표가 많다 — 뒷 칸들에서 규격 후보를 모은다. + + 실측 배치: `['굴착기+부착용집게', '0.2㎥', 'hr', '2.71', …]` · + `['굴착기 (무한궤도)', '굴착기(무한궤도,0.2㎥)', 'hr', '0.80', …]` + """ + found: list[str] = [] + for cell in cells: + text = _normalize(cell) + if not text or len(text) > 30: + continue + _, spec = parse_machine_cell(text) + if spec: + found.append(spec) + continue + token = _RE_SIZE_TOKEN.fullmatch(text.rstrip("㎥㎡톤tonm³")) + if token: + found.append(token.group(0)) + return found + + +def parse_amount(cell: str) -> Decimal | None: + """숫자 셀만 값으로 본다. 숫자가 아니면 None — 억지로 읽지 않는다.""" + text = _normalize(cell) + if not _RE_NUMBER.match(text): + return None + try: + return Decimal(text.replace(",", "")) + except InvalidOperation: # pragma: no cover - 정규식이 먼저 거른다 + return None + + +#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25). +#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다. +_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$") + + +def parse_amount_expression(cell: str) -> Decimal | None: + """「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**. + + 식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은 + 「값 × 비율%」 한 모양뿐이다. + """ + found = _RE_RATIO_EXPRESSION.match(_normalize(cell)) + if found is None: + return None + return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100) + + +def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None: + """「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`.""" + text = _normalize(cell) + found = _RE_ALTERNATIVE.match(text) + if found: + return Decimal(found.group(1)), Decimal(found.group(2)) + plain = parse_amount(text) + return None if plain is None else (plain, None) + + +def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal: + """표 형태에 맞춰 소요량으로 환산한다. + + ⚠ **여기가 20배 틀리는 자리다.** + - `productivity`(생산량형, 예 「㎥/1인/1일」) → **1 ÷ 값** + - `requirement`(소요량형, 예 「100㎥당 인부 x인」) → **값 ÷ basis_quantity** + """ + if pum_form == "productivity": + if raw == 0: + raise ResourceAxisError("생산량이 0 이라 소요량으로 뒤집을 수 없습니다") + return Decimal(1) / raw + if pum_form == "requirement": + divisor = basis_quantity if basis_quantity else Decimal(1) + if divisor == 0: + raise ResourceAxisError("기준 수량이 0 입니다") + return raw / divisor + raise ResourceAxisError(f"자원 축을 붙일 수 없는 표 형태입니다: {pum_form}") diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py new file mode 100644 index 00000000..d1707a1c --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_Transposed.py @@ -0,0 +1,596 @@ +"""B09 원가계산 — **열이 자원인 표** 읽기 (자원 축 보조, 2026-09-08). + +품셈 표에는 자원이 **행**이 아니라 **열 머리**에 오는 모양이 따로 있다 (39 표). + + 구 분 | 콘크리트공(인) | 보통인부(인) + 무근구조물 | 0.12 | 0.15 + 철근구조물 | 0.14 | 0.16 + +행은 **규격 갈래**(무근·철근·소형구조물)이고 갈래마다 품이 다르다. 이 모양을 행-자원 +표로 읽으면 통째로 안 맞는다 — 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다. + +⚠ **자리 밀림을 고쳐 읽지 않는다.** 첫 칸이 병합된 표는 값이 한 칸씩 밀려 오는데 +(목재틀흙막이가 「건축목공 8.760」 자리에 등급 글자를 두어 단가가 503만원으로 섰다), +밀린 행은 **버리고 `unmatched` 에 남긴다.** 어느 칸이 어느 자원인지 단정할 수 없다. + +`B09_Estimation_ResourceAxis` 가 700줄 제한에 걸려 이 표 모양만 떼어 낸 파일이다. +""" + +from __future__ import annotations + +import re + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + RANGE_DASH_CLASS, + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +#: 갈래 이름 자리에서 걸러 낼 말 — **합계 줄만**이다. 넓게 잡으면 등급이 지워진다. +_TOTAL_LABELS = ("계", "합계", "소계", "총계", "구분") + + +def _normalize_label(text: str) -> str: + return "".join(str(text).split()) + + +#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향). +_SUM_GROUP_LABELS = ("계", "합계", "소계", "총계") + + +def _sum_group_positions(headers: list, resource_count: int) -> set: + """「계」 묶음이 차지하는 열 번호. 2단 머리에서 묶음 하나가 여러 열을 먹는다. + + 첫 줄이 `구조별 | 가공 | 조립 | 계` 이고 둘째 줄이 `철근공 | 보통인부` × 3 벌이면 + 묶음 하나가 **2열씩** 차지한다. 「계」 묶음의 열은 통째로 뺀다. + """ + groups = [str(h).strip() for h in headers[1:]] + if not groups or resource_count % len(groups) != 0: + return set() + per_group = resource_count // len(groups) + blocked = set() + for index, group in enumerate(groups): + if "".join(group.split()) in _SUM_GROUP_LABELS: + start = index * per_group + blocked.update(range(start, start + per_group)) + return blocked + + +def second_row_columns(table: dict, catalog: ResourceCatalog): + """**첫 자료 행이 진짜 열 머리**인 2단 표를 읽는다. + + `condition_note` 가 공정(「가공·조립·계」)뿐이고 자원 이름이 그 아래 줄에 오는 표다 + (철근 현장가공 및 조립 12-3). 자원을 못 찾으면 빈 목록을 돌려준다. + """ + rows = table.get("raw_row") or [] + if not rows: + return [], 0 + + # ⚠ **첫 줄 머리가 되풀이되면 좌우 두 판짜리 표다** — 2단이라도 마찬가지다 + # (뿌리돌림 05-2: `근원직경(㎝) | 수 량 | 근원직경(㎝) | 수 량`). + # 이걸 안 가르면 오른쪽 판의 **직경 100 이 「특별인부 100인」**으로 읽혀 + # 단가가 2,436만원으로 선다(2026-09-08 실측). 공정 머리(가공·조립·계)는 + # 되풀이가 없으므로 이 검사에 안 걸린다. + groups = ["".join(str(h).split()) for h in (table.get("condition_note") or [])[1:]] + if len(groups) != len(set(groups)): + return [], 0 + + header_row = [str(c).strip() for c in rows[0]] + found = [] + for position, cell in enumerate(header_row): + if not cell: + continue + name, spec = split_name_and_spec(cell) + entry = catalog.resolve(name, spec) + if entry is None and not spec: + candidates = catalog.by_name(name) + entry = candidates[0] if len(candidates) == 1 else None + if entry is not None: + found.append((position, entry)) + if len(found) < 2: + return [], 0 + + # ⚠ **머리 줄의 이름을 하나라도 못 풀면 자리를 맞출 수 없다.** 드론방제 08-6-2 는 + # 「드론조종자·부조종자」가 카탈로그에 없어 넷 중 둘만 풀리는데, 그대로 두면 + # 숫자 두 개짜리 행이 **엉뚱한 직종 둘**에 붙는다. 통째로 버린다. + named = [cell for cell in header_row if cell] + if len(found) != len(named): + return [], 0 + + # ⚠ 「계」 묶음은 뺀다 — 가공 + 조립을 이미 더한 값이라 같이 읽으면 두 번 센다. + # ⚠ **자리를 고정 보정으로 맞추지 않는다.** 라벨 칸이 하나인 표도 둘인 표도 있어 + # (드론방제 08-6-2 는 `구 분 | 항 목` 둘) 「한 칸 밀림」으로 단정하면 값이 어긋난다 + # — 실측에서 「특별인부 0.2352」 자리에 다른 직종 값이 붙었다. + # 대신 **자료 행의 숫자 칸을 순서대로** 맞추고, 개수가 다르면 그 행을 버린다. + blocked = _sum_group_positions(table.get("condition_note") or [], len(found)) + return [(order, entry, order in blocked) for order, (_, entry) in enumerate(found)], 1 + + +def transposed_columns(table: dict[str, Any], catalog: ResourceCatalog) -> list[tuple[int, Any]]: + """열 머리에서 자원을 찾는다. `[(열 번호, 카탈로그 줄)]`. + + 첫 칸은 갈래 이름(「구 분」)이라 **1번 열부터** 본다. 카탈로그에 있는 이름만 + 자원으로 본다 — 필터로 거르지 않는다(넓은 필터가 정상 자원을 지운 전례). + """ + headers = table.get("condition_note") or [] + found: list[tuple[int, Any]] = [] + for position, header in enumerate(headers[1:], start=1): + name, spec = split_name_and_spec(str(header)) + entry = catalog.resolve(name, spec) + if entry is None and not spec: + candidates = catalog.by_name(name) + entry = candidates[0] if len(candidates) == 1 else None + if entry is not None: + found.append((position, entry)) + return found + + +def _match_two_row_table( + node: dict, + table: dict, + catalog: ResourceCatalog, + result: AxisResult, + unit: str, + ordinal: list, + skip_rows: int, +) -> bool: + """2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다. + + 개수가 다른 행은 **버린다.** 라벨 칸 수가 표마다 달라(하나 또는 둘) 자리를 + 단정할 수 없기 때문이다. 「계」 묶음에 든 자원은 맞춘 뒤 뺀다 — + 가공 + 조립을 이미 더한 값이라 같이 세면 두 번이다. + """ + work_item_code = node.get("work_item_code", "") + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + rows = (table.get("raw_row") or [])[skip_rows:] + labels = [str(row[0]).strip() for row in rows if row] + repeated = {label for label in labels if label and labels.count(label) > 1} + matched = False + + for index, row in enumerate(rows): + cells = [str(c).strip() for c in row] + if not cells: + continue + variant = cells[0] + if not variant or _normalize_label(variant) in _TOTAL_LABELS or variant in repeated: + continue + numbers = [parse_amount(c) for c in cells[1:]] + numbers = [value for value in numbers if value is not None] + if len(numbers) != len(ordinal): + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=variant, + reason=( + f"숫자 칸 {len(numbers)} 개가 자원 열 {len(ordinal)} 개와 안 맞아 " + "버렸습니다(자리 밀림 방지)." + ), + ) + ) + continue + for (order, entry, blocked), amount in zip(ordinal, numbers): + if blocked: + continue # 「계」 묶음 — 이미 더한 값이다 + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index + skip_rows, + variant=variant, + ) + ) + matched = True + return matched + + +#: 「석공 보통인부」처럼 **여러 자원이 한 칸에** 뭉쳐 오고, 값도 「0.09 0.05」로 뭉쳐 오며, +#: 열이 규격 갈래(35cm 이하 · 55cm 이하 · 75cm 이하)인 표. 돌쌓기 13-4 계열이 그 모양이다. +#: +#: ⚠ **개수가 하나라도 안 맞으면 표째 버린다** — 이름 2 개에 값 3 개면 어느 값이 누구 +#: 것인지 단정할 수 없다. 오늘 여러 번 겪은 자리다. +_RE_PACKED_NUMBER = re.compile(r"\d+(?:\.\d+)?") + + +#: 「1.04(1.17)」의 괄호 값 — **조건 시공 시 대안값**이라 기본 수에 안 센다 +#: (품셈 13-6-1 [주]②). 안 떼면 값이 하나 더 있는 것으로 보여 표가 통째로 버려진다. +_RE_ALTERNATIVE_TAIL = re.compile(r"(?<=\d)\s*[((]\s*\d+(?:\.\d+)?\s*[))]") + + +def _packed_numbers(cell: str) -> list: + text = _RE_ALTERNATIVE_TAIL.sub("", str(cell)) + return [Decimal(t) for t in _RE_PACKED_NUMBER.findall(text)] + + +def _packed_names(cell: str) -> list[str]: + """한 칸에 뭉친 이름들. 「굴착기+부착용 집게」는 **조합 기종**이라 통째로 둔다.""" + text = " ".join(str(cell).split()) + if "+" in text: + return [text] + return [part for part in text.split(" ") if part] + + +def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None = None) -> list: + """이름 하나를 카탈로그 줄들로 푼다. + + 「굴착기+부착용 집게」처럼 **두 기종을 함께 쓰는 조합**은 둘 다 돌려준다 — + 같은 시간을 둘이 함께 쓰므로 사용료도 둘 다 붙는다. + ⚠ TODO(미결) 조합 표기의 해석은 잠정이다 — 사용자·메인 확인 대기(PLAN 9-6). + """ + parts = [part.strip() for part in str(name).split("+") if part.strip()] + found = [] + for part in parts: + base, spec = split_name_and_spec(part) + entry = catalog.resolve(base, spec) + if entry is None and not spec: + candidates = catalog.by_name(base) + entry = candidates[0] if len(candidates) == 1 else None + if entry is None: + entry = _resolve_with_side_spec(catalog, base, specs or []) + if entry is None: + return [] + found.append(entry) + return found + + +#: 갈래(무한궤도/타이어)를 안 적은 기종을 고를 때의 **잠정** 우선순위. +#: 품셈 13-4 는 「굴착기+부착용 집게 | 0.6㎥」로만 적는데 카탈로그는 갈래까지 나뉜다. +#: ⚠ TODO(미결) 임도 현장 표준이 무한궤도라 그쪽을 잠정 채택 — 사용자 확정 대기(PLAN 9-6). +_TRACK_PREFERENCE = ("무한궤도",) + + +_RE_SPEC_RANGE = re.compile(rf"^(\d+(?:\.\d+)?)[{RANGE_DASH_CLASS}](\d+(?:\.\d+)?)$") + + +def _spec_matches(catalog_spec: str, wanted: str) -> bool: + """카탈로그 규격이 표의 규격을 담는가. + + 카탈로그가 **범위**로 적는 경우가 있다 — 「부착용 집게 0.6∼0.8」은 0.6㎥ 를 담는다. + 범위 밖이면 안 고른다. + """ + if catalog_spec == wanted or wanted.startswith(catalog_spec): + return True + found = _RE_SPEC_RANGE.match(catalog_spec) + if not found: + return False + numbers = _RE_PACKED_NUMBER.findall(wanted) + if not numbers: + return False + value = Decimal(numbers[0]) + return Decimal(found.group(1)) <= value <= Decimal(found.group(2)) + + +def _resolve_with_side_spec(catalog: ResourceCatalog, name: str, specs: list[str]): + """이름에 갈래가 없고 규격이 **옆 칸**에 있는 기종을 고른다. + + 이름이 카탈로그 이름의 앞머리이고 규격이 **정확히 같을 때만** 고른다 — + 규격이 다르면 안 고른다(엉뚱한 기종이 붙으면 사용료가 통째로 틀린다). + """ + if not name or not specs: + return None + wanted = {"".join(str(s).split()) for s in specs if str(s).strip()} + hits = [] + for entry in catalog.entries: + if not entry.name.startswith(name): + continue + spec = "".join(str(entry.spec).split()) + if spec and any(_spec_matches(spec, w) for w in wanted): + hits.append(entry) + if not hits: + return None + if len(hits) == 1: + return hits[0] + for word in _TRACK_PREFERENCE: + preferred = [e for e in hits if word in e.name] + if len(preferred) == 1: + return preferred[0] + return None + + +def match_packed_rows( + node: dict, + table: dict, + catalog: ResourceCatalog, + result: AxisResult, + basis_quantity, + unit: str, +) -> bool: + """뭉친 이름 + 갈래 열 표를 읽는다. 그런 표가 아니면 `False`.""" + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if len(rows) < 2: + return False + + labels = [cell for cell in rows[0] if cell] + # ⚠ 라벨에 수가 들어 있을 수 있다 — 「35cm 이하」. 수의 유무로 가르면 안 된다. + # 자원으로 **안 풀리는** 줄이면 갈래 라벨 줄로 본다. + if not labels: + return False + # ⚠ **라벨 줄에는 순수 숫자 칸이 없다.** 「35cm 이하」는 수를 품되 순수 수가 아니고, + # 「자재 | 종 자 | | kg | 0.025」는 순수 수(0.025)가 있는 **자료 줄**이다. + # 이 구분을 안 두면 씨앗뿜어붙이기 표를 라벨 줄로 오해해 표째 가로챈다(2026-09-08 회귀). + if any(parse_amount(cell) is not None for cell in rows[0]): + return False + # ⚠ **첫 줄의 어느 칸이라도 자원이면 라벨 줄이 아니다.** 첫 칸만 보면 기초잡석 + # 12-25 처럼 「소할(30%) | 할석공(인) | 0.2 × 30%」인 표를 라벨 줄로 오해해 + # 표째 가로챈다(2026-09-08: 그 탓에 기초잡석이 다시 막혔다). + for cell in rows[0]: + if not cell: + continue + if any(_resolve_packed(catalog, name, []) for name in _packed_names(cell)): + return False + + # ⚠ **뭉친 표에만 쓴다.** 이 길이 넓으면 행-자원 표까지 가로채 자원 축이 줄어든다 + # (2026-09-08 실측: 304 → 244 줄로 떨어졌다). 이름이 둘 이상 뭉쳤거나 값이 한 칸에 + # 둘 이상 뭉친 줄이 **하나라도** 있어야 이 표로 본다. + packed = False + for cells in rows[1:]: + if not cells or not cells[0]: + continue + if len(_packed_names(cells[0])) > 1: + packed = True + break + if any(len(_packed_numbers(cell)) > 1 for cell in cells[1:]): + packed = True + break + if not packed: + return False + + work_item_code = node.get("work_item_code", "") + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + staged: list = [] + + for index, cells in enumerate(rows[1:], start=1): + if not cells or not cells[0]: + continue + # 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③). + # 갈래마다 같은 값이 되풀이되므로 **첫 값 칸**만 본다. 「9(9) 3(3)」 = 윗단 9 · 아랫단 3. + if "제잡비" in _normalize_label(cells[0]).replace(" ", ""): + for cell in cells[1:]: + numbers = _packed_numbers(cell) + if numbers: + upper = numbers[0] + lower = numbers[1] if len(numbers) > 1 else numbers[0] + result.overhead_ratio[work_item_code] = (upper, lower) + break + continue + + # 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」. + side_specs = [cell for cell in cells[1:3] if cell] + # ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에 + # 공백이 있으면 쪼개서 보다가 통째로 못 푼다(2026-09-08: 찰쌓기 13-6-2 의 + # 장비 몫이 그래서 빠지고 공종이 막혔다). + whole = _resolve_packed(catalog, _normalize_label(cells[0]), side_specs) + if whole: + names, resolved = [_normalize_label(cells[0])], [whole] + else: + names = _packed_names(cells[0]) + resolved = [_resolve_packed(catalog, name, side_specs) for name in names] + if not all(resolved): + if _packed_numbers(" ".join(cells[1:])): + # 수가 있는데 이름을 못 풀었다 — 그 몫이 빠진 채 서면 안 된다. + result.partial_items[work_item_code] = f"{cells[0][:20]} 줄을 못 풀었습니다" + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0], + reason="뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + ) + ) + continue + + groups = [_packed_numbers(cell) for cell in cells[1:]] + groups = [group for group in groups if group] + # 앞쪽에 규격·단위 칸이 낄 수 있다 — 「0.6㎥ | 시간 | 0.31 | 0.30 | 0.28」. + # 갈래 수만큼 **뒤에서** 잘라 쓴다. + if len(groups) > len(labels): + groups = groups[-len(labels) :] + if len(groups) != len(labels) or any(len(g) != len(names) for g in groups): + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0], + reason=( + f"값 묶음 {len(groups)} 개가 갈래 {len(labels)} 개와 안 맞습니다 " + "— 자리를 단정할 수 없어 버렸습니다." + ), + ) + ) + return True + + for label, group in zip(labels, groups): + for entries, amount in zip(resolved, group): + value = amount + if basis_quantity not in (None, 0, Decimal(1)): + value = value / basis_quantity + for entry in entries: + staged.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=value, + amount_unit=unit, + raw_row_index=index, + variant=label, + ) + ) + + if not staged: + return False + result.rows.extend(staged) + return True + + +def match_transposed_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, + basis_quantity: Decimal | None, + unit: str, +) -> bool: + """열이 자원인 표를 읽는다. 그런 표가 아니면 `False` 를 돌려 원래 길로 보낸다. + + 행마다 **규격 갈래 하나**가 되므로 `variant` 를 달아 둔다 — 「무근구조물」과 + 「철근구조물」은 품이 달라 **한 일위대가로 뭉치면 안 된다**. + """ + columns = transposed_columns(table, catalog) + skip_rows = 0 + ordinal: list = [] + if not columns: + # 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다. + ordinal, skip_rows = second_row_columns(table, catalog) + if ordinal: + return _match_two_row_table(node, table, catalog, result, unit, ordinal, skip_rows) + if not columns: + return False + + # ⚠ **좌우로 두 판이 붙은 표는 통째로 버린다.** 열 머리가 되풀이되면 + # (「거리 | 보통인부 | 거리 | 보통인부」) 오른쪽 판의 **거리값이 인원으로** 읽힌다 + # — 2026-09-08 실측: 소운반이 「보통인부 60인」이 되어 단가가 1,553만원으로 섰다. + # 판 경계를 짐작해 읽지 않는다. + # ⚠ 2단 표에서는 **같은 직종이 공정마다 되풀이되는 것이 정상**이다 + # (철근 12-3: 가공 철근공 + 조립 철근공 = 합쳐야 맞는 값). 되풀이 금지는 + # **1단 표에만** 건다 — 거기서만 「두 판이 좌우로 붙은 표」를 뜻한다. + codes = [entry.code for _, entry in columns] + if skip_rows == 0 and len(codes) != len(set(codes)): + result.unmatched.append( + UnmatchedRow( + work_item_code=node.get("work_item_code", ""), + pum_table_id=str(table.get("pum_table_id", "")), + cell=" | ".join(str(c) for c in (table.get("condition_note") or [])), + reason="열 머리가 되풀이되는 두 판 짜리 표 — 자리를 단정할 수 없어 버렸습니다.", + ) + ) + return True + + # ⚠ **갈래 이름이 되풀이되면 그 줄들을 버린다.** 첫 칸이 병합된 표에서 상위 등급이 + # 떨어져 나가면 「상」이 두 번 나오고, 그대로 두면 서로 다른 등급의 품이 **합산**된다 + # (2026-09-08 실측: 목재틀흙막이 「상」이 8.760 + 13.767 = 22.5 인이 되어 단가가 + # 667만원으로 섰다). 어느 등급인지 단정할 수 없으므로 고쳐 읽지 않는다. + labels = [str(row[0]).strip() for row in (table.get("raw_row") or [])[skip_rows:] if row] + repeated = {label for label in labels if label and labels.count(label) > 1} + + work_item_code = node.get("work_item_code", "") + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + matched_any = False + + for index, row in enumerate(table.get("raw_row", [])): + if index < skip_rows: + continue # 그 줄은 자료가 아니라 **열 머리**다 + cells = [str(c) for c in row] + if not cells: + continue + variant = cells[0].strip() + # ⚠ 첫 칸은 **갈래 이름**이지 자원 이름이 아니다 — 자원용 머리글 필터를 여기 쓰면 + # 「중」·「상」 같은 정상 등급이 통째로 지워진다(2026-09-08 실측: 목재틀흙막이의 + # 중·상 등급이 사라지고 상등구조만 남았다). 합계 줄만 걸러 낸다. + if not variant or _normalize_label(variant) in _TOTAL_LABELS: + continue + if variant in repeated: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=variant, + reason="같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.", + ) + ) + continue + # ⚠ **자리 밀림 검사** — 자원 열 가운데 하나라도 수가 아니면 그 행은 밀린 것이다. + # 첫 칸이 병합된 표에서 값이 한 칸씩 밀려 들어온다(2026-09-08 실측: 목재틀흙막이가 + # 「건축목공 8.760」 자리에 등급 글자를 두어 단가가 503만원으로 섰다). + # **밀린 행은 고쳐 읽지 않고 버린다** — 어느 칸이 어느 자원인지 단정할 수 없다. + # ⚠ **숫자 칸이 자원 열보다 많으면 차원이 하나 더 있는 표다.** + # 뭉기기 13-12-1 은 행이 공정, 숫자 칸이 토질 3갈래인데 열 머리엔 자원이 하나뿐이라 + # 그대로 두면 **첫 토질 값만 조용히 취한다**(보통토사 0.16 만 서고 나머지가 사라짐). + numeric_count = sum(1 for cell in cells[1:] if parse_amount(cell) is not None) + if numeric_count > len(columns): + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=variant, + reason=( + f"숫자 칸 {numeric_count} 개가 자원 열 {len(columns)} 개보다 많습니다 — " + "갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다." + ), + ) + ) + continue + + readable = [ + parse_amount(cells[position]) if position < len(cells) else None + for position, _ in columns + ] + if any(value is None for value in readable) and any( + value is not None for value in readable + ): + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=variant, + reason="자원 열의 값이 한 칸 밀린 행 — 자리를 단정할 수 없어 버렸습니다.", + ) + ) + continue + + for position, entry in columns: + if position >= len(cells): + # 칸이 모자란 행 — **자리를 밀어 읽지 않는다**. 밀려 읽으면 다른 직종의 + # 품이 붙는다(기계경비 표에서 실제로 겪은 사고). + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=f"{variant} / {entry.name}", + reason="칸 수가 열 머리와 안 맞아 버렸습니다(자리 밀림 방지).", + ) + ) + continue + amount = parse_amount(cells[position]) + if amount is None: + continue + if basis_quantity not in (None, 0, Decimal(1)): + amount = amount / basis_quantity + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=variant, + ) + ) + matched_any = True + return matched_any diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py new file mode 100644 index 00000000..f594434b --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py @@ -0,0 +1,151 @@ +"""B09 자원 축 — **한 표에 밑수 단위가 둘인 표** (2026-09-08 발견). + +품셈에는 같은 품을 **㎡당과 ㎥당 두 벌**로 주는 표가 있다. + + 13-2-2 막돌 채집 (단위: ㎥당) + 보통인부 | ㎡당 | 0.17 + | ㎥당 | 0.64 + + 13-2-4 야면석 채집(인력) 뒷길이(㎝) 25 35 45 55 60 + 인부 | ㎡당 0.11 0.17 0.22 0.28 0.36 + | ㎥당 0.60 0.64 0.67 0.70 0.80 + +⚠ **행-자원으로 읽으면 앞줄만 잡고 뒷줄을 버린다.** 실제로 막돌 채집이 **㎡당 0.17 을 +쓰면서 단위는 ㎥** 로 서 있었다(㎥당은 0.64 — **3.8 배**). 야면석은 열이 다섯이라 아예 +안 섰다. 둘 다 조용히 틀린 자리다. + +**그래서 밑수마다 따로 세운다.** 갈래 이름에 밑수를 넣고(`#45㎝·㎥당`), 그 갈래의 +**단위를 그 밑수로** 준다 — 그러면 내역서의 단위 불일치 검사가 **엉뚱한 밑수를 걸러 준다**. + +⚠ **밑수 표기가 하나뿐인 표는 건드리지 않는다.** 그런 표는 여태 하던 길이 맞고, +넓게 잡으면 멀쩡한 표까지 갈래가 둘로 쪼개진다. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +#: 「㎡당」·「㎥당」처럼 **밑수를 말하는 칸**. 앞에 수가 붙으면(「10㎡당」) 여기가 아니라 +#: 밑수(basis_quantity) 자리라 건드리지 않는다. +_RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$") + +#: 밑수 표기 → 그 갈래가 갖는 단위. +_BASIS_UNIT = {"㏊": "ha", "톤": "ton", "㎏": "kg"} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def _basis_of(cell: str) -> str | None: + """그 칸이 밑수 표기면 단위, 아니면 `None`.""" + found = _RE_BASIS.match(_tight(cell)) + if not found: + return None + unit = found.group(1) + return _BASIS_UNIT.get(unit, unit) + + +def _column_labels(table: dict[str, Any]) -> list[str]: + """열 머리가 갈래인 표의 갈래 이름들 — 「25 35 45 55 60」. 없으면 빈 목록.""" + headers = [_tight(c) for c in (table.get("condition_note") or [])] + labels = [cell for cell in headers[1:] if cell and parse_amount(cell) is not None] + return labels + + +def match_unit_basis_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """밑수가 둘 이상인 표를 밑수별 갈래로 편다. 그런 표가 아니면 `False`.""" + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return False + + # 밑수 표기가 **둘 이상** 있어야 이 길이다 — 하나뿐이면 여태 하던 길이 맞다. + seen_basis = {b for row in rows for cell in row if (b := _basis_of(cell))} + if len(seen_basis) < 2: + return False + + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + labels = _column_labels(table) + + entry = None + made = 0 + for index, cells in enumerate(rows): + basis_at = next((i for i, cell in enumerate(cells) if _basis_of(cell)), None) + if basis_at is None: + continue + if basis_at > 0: + # 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다. + found = _resolve(catalog, cells[0]) + if found is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0][:30], + reason="밑수 둘인 표인데 자원 이름을 못 풀었습니다", + ) + ) + return True # 다른 길로 보내지 않는다 — 앞줄만 잡고 뒷줄을 버리게 된다 + entry = found + if entry is None: + continue + + unit = _basis_of(cells[basis_at]) + values = [parse_amount(cell) for cell in cells[basis_at + 1 :]] + values = [v for v in values if v is not None] + if not values: + continue + + for position, amount in enumerate(values): + if labels and position >= len(labels): + break # 비고 칸까지 값으로 읽지 않는다 + label = labels[position] if labels else "" + # ⚠ 갈래 이름에 **밑수를 함께** 적는다 — 안 적으면 ㎡당과 ㎥당이 같은 갈래로 + # 뭉쳐 하나가 덮인다(막돌 채집이 그렇게 ㎡당 값을 ㎥ 단위로 달고 있었다). + variant = f"{label}㎝·{unit}당" if label else f"{unit}당" + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=variant, + ) + ) + made += 1 + return made > 0 + + +def _resolve(catalog: ResourceCatalog, name_cell: str): + name, spec = split_name_and_spec(name_cell) + entry = catalog.resolve(name, spec) + if entry is not None: + return entry + if spec: + return None + found = catalog.by_name(name) + return found[0] if len(found) == 1 else None diff --git a/B09_Estimation/B09_Estimation_Rounding.py b/B09_Estimation/B09_Estimation_Rounding.py new file mode 100644 index 00000000..e56eaf3e --- /dev/null +++ b/B09_Estimation/B09_Estimation_Rounding.py @@ -0,0 +1,86 @@ +"""B09 원가계산 — 단수 처리는 **출력 위치**에 붙는다. + +`resources/knowledge/technical_info/01_임도/05_원가정보/단수처리_규칙.md` §1 관측: +같은 「수량 × 단가」라도 **내역서 본체는 절사(ROUNDDOWN), 집계표는 반올림(ROUND)** 이다. +즉 단수 함수는 **항목이 아니라 표 종류에 바인딩**된다. + +그래서 이 모듈이 있는 자리 — + - **계산 함수 안에서 자르지 않는다.** 계산은 전정밀 `Decimal` 로 내고, + **표를 그리는 자리에서** 이 모듈의 함수로 자른다. + - ⚠ **집계표(반올림)와 본체(절사)를 더하면 합이 1원 단위로 어긋나는 것이 정상**이다. + 나중에 「합계가 안 맞는다」는 지적이 반드시 나오는데, **그때 계산을 고치면 안 된다.** + 어긋남 자체가 규칙이다. + +값은 원문에서 복사하지 않는다 — 자릿수·함수만 여기 두고 근거는 위 문서를 가리킨다. +""" + +from __future__ import annotations + +from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal +from enum import Enum + +_ONE = Decimal(1) +_THOUSAND = Decimal(1000) + + +class OutputPlace(str, Enum): + """숫자가 찍히는 자리. 자리마다 단수 함수가 다르다.""" + + #: 설계내역서 행 금액(수량×단가) — 절사 + BOQ_ROW = "boq_row" + #: 제경비 각 항목(밑수×율) — 행별 절사 + OVERHEAD_ROW = "overhead_row" + #: 조달수수료(관급×율) — 절사 + PROCUREMENT_FEE = "procurement_fee" + #: 자원 집계표(재료·노무·경비·중기) — **반올림** + RESOURCE_SUMMARY = "resource_summary" + #: 관급자재대 총액 — **천원 올림** + OWNER_MATERIAL_TOTAL = "owner_material_total" + #: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」) + UNIT_PRICE_ROW = "unit_price_row" + #: 일위대가표의 **계금** — 1원 미만 버림. ⚠ 금액란(0.1원)과 **자리가 다르다** + #: (품셈 1-2-2의 2 표에 두 줄이 따로 있다). + UNIT_PRICE_TOTAL = "unit_price_total" + #: **설계서의 총액** — 1,000원 미만 버림 (품셈 1-2-2의 2 「설계서의 총액 … 1,000 미만버림」). + #: ⚠ 소계·금액란(1원)과 다르다. 맨 마지막 한 자리에서만 쓴다. + GRAND_TOTAL = "grand_total" + + +def round_at(value: Decimal, place: OutputPlace) -> Decimal: + """그 자리의 규칙대로 자른다. + + 자리를 안 대고 부르는 길을 두지 않는다 — 기본값을 두면 어느 자리인지 모른 채 + 아무 함수나 쓰게 된다. + """ + if place is OutputPlace.GRAND_TOTAL: + return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_FLOOR) * _THOUSAND + if place in ( + OutputPlace.BOQ_ROW, + OutputPlace.OVERHEAD_ROW, + OutputPlace.PROCUREMENT_FEE, + OutputPlace.UNIT_PRICE_TOTAL, + ): + return value.quantize(_ONE, rounding=ROUND_FLOOR) + if place is OutputPlace.RESOURCE_SUMMARY: + return value.quantize(_ONE, rounding=ROUND_HALF_UP) + if place is OutputPlace.UNIT_PRICE_ROW: + return value.quantize(Decimal("0.1"), rounding=ROUND_FLOOR) + if place is OutputPlace.OWNER_MATERIAL_TOTAL: + return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_CEILING) * _THOUSAND + raise ValueError(f"단수 처리 자리를 모릅니다: {place}") + + +#: 집계표와 본체를 나란히 보일 때 화면 비고에 다는 문구. +#: 「합이 1원 안 맞는다」는 지적에 계산을 고치지 않게 하려는 것이다. +SUMMARY_MISMATCH_NOTE = ( + "집계표는 반올림, 내역서 본체는 절사 — 두 표의 합이 원 단위로 어긋나는 것은 정상입니다." +) + + +def summary_vs_body_gap(summary_total: Decimal, body_total: Decimal) -> Decimal: + """집계표 합계와 내역서 본체 합계의 차이. + + **0 이 아닌 것이 정상**이다. 화면에 그 차이를 숨기지 않고 보여, 설계자가 + 「어긋남이 규칙임」을 알고 넘어가게 한다. + """ + return summary_total - body_total diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py new file mode 100644 index 00000000..bf58a57c --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router.py @@ -0,0 +1,317 @@ +"""B09 원가계산 라우터 — ⑤ 공사원가계산서 계산 결과를 화면에 낸다. + +지금은 **무상태 계산 엔드포인트**다. 순공사비를 받아 원가계산서 한 장을 돌려주고, +저장은 하지 않는다. 프로젝트 저장(채택 단가 스냅샷 `B09_Estimation/v1/`)은 PLAN 9-2 +항목으로 뒤에 붙인다. + +화면이 「비목 · 금액 · 요율 · 산출근거」 네 칸을 다 보이므로 (PLAN 8-13) 줄마다 그 넷을 +그대로 실어 보낸다. 안전관리비는 A·B 두 줄이 나란히 오고 `note` 에 채택 표시가 붙는다. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import replace as dataclass_replace +from decimal import Decimal +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B09_Estimation.B09_Estimation_Engine_Cost import ( + CostInput, + CostResult, + calculate_cost, + proposed_profit_adjustment, +) +from B09_Estimation.B09_Estimation_PriceBook import PriceBookError +from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill +from B09_Estimation.B09_Estimation_Guards import DoubleCountError +from B09_Estimation.B09_Estimation_Rates import RateLookupError +from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import ( + build_summary, + cached_build, + detail_of, + direct_cost_from_quantities, + list_unit_prices, +) +from common_util.common_util_workflow_state import complete_stage +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) + + +class CostRequest(BaseModel): + """원가계산 입력 — 금액은 원 단위.""" + + direct_material_krw: Decimal = Field(default=Decimal(0), ge=0) + direct_labor_krw: Decimal = Field(default=Decimal(0), ge=0) + direct_expense_krw: Decimal = Field(default=Decimal(0), ge=0) + indirect_material_krw: Decimal = Field(default=Decimal(0), ge=0) + + work_type_indirect_labor: str = "civil" + work_type_safety: str = "civil" + duration_days: int = Field(default=183, ge=1) + pension_year: int = 2026 + + owner_supplied_material_krw: Decimal = Field(default=Decimal(0), ge=0) + procurement_fee_krw: Decimal = Field(default=Decimal(0), ge=0) + include_fee_in_owner_material_total: bool = True + owner_supplied_for_safety_krw: Decimal | None = None + owner_supplied_includes_vat: bool = True + + estimated_price_krw: Decimal | None = None + profit_adjustment_krw: Decimal = Field(default=Decimal(0), ge=0) + waste_disposal_krw: Decimal = Field(default=Decimal(0), ge=0) + + environment_work_type: str = "civil_road" + equipment_guarantee_work_type: str = "civil_general" + subcontract_guarantee_variant: str = "integrated_civil_or_industrial" + + rate_file_name: str = "rates_2026.json" + + #: 공종별 수량 `{공종코드: 수량}`. 주면 **직접비 3분할을 여기서 만들어** 쓴다. + #: ⚠ 일위대가 합계를 뭉쳐 넣지 않는다 — 밑수가 항목마다 갈린다(PLAN 8-9 규칙 2). + #: 지금 원천은 **손입력**이고, B08 인계(9번)가 나오면 **원천만 바꿔 끼운다**. + quantities: dict[str, Decimal] | None = None + + #: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**. + #: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다. + target_contract_amount_krw: Decimal | None = None + + def to_engine_input(self) -> CostInput: + return CostInput( + direct_material_krw=self.direct_material_krw, + direct_labor_krw=self.direct_labor_krw, + direct_expense_krw=self.direct_expense_krw, + indirect_material_krw=self.indirect_material_krw, + work_type_indirect_labor=self.work_type_indirect_labor, + work_type_safety=self.work_type_safety, + duration_days=self.duration_days, + pension_year=self.pension_year, + owner_supplied_material_krw=self.owner_supplied_material_krw, + procurement_fee_krw=self.procurement_fee_krw, + include_fee_in_owner_material_total=self.include_fee_in_owner_material_total, + owner_supplied_for_safety_krw=self.owner_supplied_for_safety_krw, + owner_supplied_includes_vat=self.owner_supplied_includes_vat, + estimated_price_krw=self.estimated_price_krw, + profit_adjustment_krw=self.profit_adjustment_krw, + waste_disposal_krw=self.waste_disposal_krw, + environment_work_type=self.environment_work_type, + equipment_guarantee_work_type=self.equipment_guarantee_work_type, + subcontract_guarantee_variant=self.subcontract_guarantee_variant, + rate_file_name=self.rate_file_name, + ) + + +def _serialize(result: CostResult) -> dict[str, Any]: + """계산 결과를 화면이 그대로 그릴 수 있는 모양으로 편다.""" + return { + "lines": [ + { + "key": line.key, + "name": line.name, + "base_label": line.base_label, + "base_amount_krw": str(line.base_amount_krw), + "rate_percent": (None if line.rate_percent is None else str(line.rate_percent)), + "flat_amount_krw": str(line.flat_amount_krw), + "amount_krw": str(line.amount_krw), + "formula_text": line.formula_text, + "note": line.note, + } + for line in result.lines + ], + "totals": {key: str(value) for key, value in result.totals.items()}, + "rate_version": result.rate_version, + "notes": result.notes, + } + + +@router.post("/{project_id}/estimation/cost") +async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse: + """공사원가계산서 한 장을 계산해 돌려준다 (저장 없음).""" + direct_source = "manual" + missing_unit_prices: list[str] = [] + try: + data = payload.to_engine_input() + if payload.quantities: + # 수량이 오면 **일위대가에서 직접비 3분할을 만들어** 갈아 끼운다. + breakdown = direct_cost_from_quantities(payload.quantities) + # ⑤ 표에 찍히는 자리라 **자원 집계표 규칙(반올림)** 으로 자른다 — + # 안 자르면 원가계산서에 소수점이 그대로 흘러나온다. + summary = OutputPlace.RESOURCE_SUMMARY + data = dataclass_replace( + data, + direct_material_krw=round_at(breakdown.material, summary), + direct_labor_krw=round_at(breakdown.labor, summary), + direct_expense_krw=round_at(breakdown.expense, summary), + ) + direct_source = "quantities" + missing_unit_prices = breakdown.missing + result = calculate_cost(data) + except RateLookupError as error: + # 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다. + logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error) + return JSONResponse(status_code=422, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 원가계산 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산에 실패했습니다."}, + ) + + body = _serialize(result) + # 어느 값으로 계산했는지 화면이 알아야 한다 — 안 보이면 나중에 못 가른다. + body["direct_cost_source"] = direct_source + # 수량은 있는데 단가가 없는 공종 — **화면에 반드시 보인다**. + body["missing_unit_prices"] = missing_unit_prices + if payload.target_contract_amount_krw is not None: + # 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다. + body["suggested_profit_adjustment_krw"] = str( + proposed_profit_adjustment(result, payload.target_contract_amount_krw) + ) + return JSONResponse(content={"status": "success", **body}) + + +@router.get("/{project_id}/estimation/items") +async def list_items(project_id: UUID) -> JSONResponse: + """비목 정의 목록 — 화면이 무엇을 켜고 끌 수 있는지 알기 위한 것.""" + return JSONResponse( + content={ + "status": "success", + "items": [ + {"key": item.key, "name": item.name, "base_label": item.base_label} + for item in STATUTORY_ITEMS + ], + } + ) + + +@router.get("/{project_id}/estimation/unit-prices") +async def list_unit_price_titles(project_id: UUID) -> JSONResponse: + """일위대가 **목록표** — 「무엇이 있나」 한 줄씩 + 산출 요약. + + 요약을 같이 보내는 까닭은 사용자가 **「무엇이 안 선 상태인가」를 화면에서** + 알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬). + """ + try: + build = cached_build() + return JSONResponse( + content={ + "status": "success", + "summary": build_summary(build), + "rows": list_unit_prices(build), + } + ) + except Exception: + logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "일위대가 목록을 못 만들었습니다."}, + ) + + +@router.get("/{project_id}/estimation/unit-prices/{code}") +async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: + """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" + try: + return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)}) + except PriceBookError as error: + return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 일위대가 본표 실패: project_id=%s, code=%s", project_id, code) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "일위대가 본표를 못 만들었습니다."}, + ) + + +@router.post("/{project_id}/estimation/confirm") +async def confirm_estimation(project_id: UUID) -> JSONResponse: + """원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다.""" + pool = get_db_pool() + async with pool.acquire() as connection: + try: + async with connection.cursor() as cursor: + await complete_stage(cursor, str(project_id), 6) + await connection.commit() + except Exception: + await connection.rollback() + logger.exception("B09 원가계산 확정 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "원가계산 단계 확정에 실패했습니다."}, + ) + return JSONResponse(content={"status": "success", "project_id": str(project_id)}) + + +@router.get("/{project_id}/estimation/bill") +async def get_bill(project_id: UUID) -> JSONResponse: + """④ 예산내역서 한 장 — B08 인계를 그대로 받아 계층을 세워 돌려준다. + + ⚠ **수량을 다시 세지 않는다.** B08 인계가 정본이고 여기서는 단가를 붙여 금액만 + 만든다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」). + + ⚠ 단가가 없거나 밑수를 모르는 줄은 **0 으로 안 때우고** `missing` 으로 드러낸다 — + 화면이 그 목록을 그대로 보인다. + """ + from B08_Quantity.B08_Quantity_Router_Material import get_handoff + + try: + response = await get_handoff(project_id) + payload = json.loads(bytes(response.body).decode("utf-8")) + except Exception: + logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id) + return JSONResponse( + status_code=502, + content={"status": "error", "message": "B08 인계 자료를 받지 못했습니다."}, + ) + if "work_items" not in payload: + # B08 이 오류 응답을 준 경우 — 그 사유를 그대로 넘긴다(감추지 않는다). + return JSONResponse(status_code=502, content={"status": "error", **payload}) + + try: + result = build_bill(payload) + except DoubleCountError as error: + # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. + logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) + return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("B09 내역서 조판 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "예산내역서를 세우지 못했습니다."}, + ) + + return JSONResponse( + content={ + "status": "success", + "rows": [row.as_dict() for row in result.rows], + "excluded": [row.as_dict() for row in result.excluded], + "materials": [row.as_dict() for row in result.material_rows], + "summary": bill_summary(result), + "price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []}, + } + ) + + +@router.get("/{project_id}/estimation/price-basis/{code}") +async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse: + """③ 단가산출서 한 장 — 그 단가가 무엇을 참조해 나왔는지.""" + from B09_Estimation.B09_Estimation_PriceBasis import price_basis_detail + + try: + body = price_basis_detail(code) + except Exception: + logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "그 단가산출서를 찾지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", **body}) diff --git a/B09_Estimation/B09_Estimation_Statutory.py b/B09_Estimation/B09_Estimation_Statutory.py new file mode 100644 index 00000000..2fd37e8b --- /dev/null +++ b/B09_Estimation/B09_Estimation_Statutory.py @@ -0,0 +1,406 @@ +"""B09 원가계산 — 법정경비 묶음 (⑤ 공사원가계산서의 경비 부분). + +`B09_Estimation_Engine_Cost` 가 부르는 하위 모듈. 700줄 제한(CLAUDE.md 4장)에 맞춰 +경비 계산만 떼어 두었다. + +지켜야 할 것 (PLAN 8-9·8-10·8-13·8-14) + - **비목 목록을 코드에 박지 않는다.** 아래 `STATUTORY_ITEMS` 는 「무엇을 어떤 밑수로 + 계산하는가」의 정의일 뿐이고, **그 해에 그 비목이 있는지는 요율 데이터가 정한다** + (해당 요율 변수가 데이터셋에 없으면 그 해에는 없는 비목). + - **밑수가 항목마다 갈린다** — 산재·고용 = 직노+간노 / 건강·연금 = 직노 / + 요양 = 건강보험료 / 안전 = 재료비+직노+관급항 / 기타경비 = 재료비+직노+간노 / + 환경·보증 = 직접공사비. 뭉뚱그리면 틀린다. + - **안전관리비는 A·B 두 값을 다 내고 작은 쪽** (고용노동부 고시 제2025-11호). + ⚠ A 가 항상 작지 않다 — 관급을 넣어 대상액이 5억·50억 경계를 넘으면 뒤집힌다 + (실증: 울진 A 채택 / 거창 B 채택). + - ★ **법대로**(8-10) — 조달수수료 차감이라는 처리는 없다. 안전관리비 관급항은 + **순자재대**(수수료를 애초에 안 포함)를 부가세 제외로 환산해 쓴다. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Callable + +from B09_Estimation.B09_Estimation_Rates import ( + RateDataset, + RateLookupError, + base_amount, + pension_rate_percent, + rate_percent, + select_bracket, +) + +if TYPE_CHECKING: # pragma: no cover - 순환 import 회피용 + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult + +_ZERO = Decimal(0) +_HUNDRED = Decimal(100) +_VAT_DIVISOR = Decimal("1.1") +_SAFETY_B_MULTIPLIER = Decimal("1.2") + +#: 공사이행보증수수료 요율 데이터가 값 대신 들고 있는 식 문자열 형태. +_RE_GUARANTEE_FORMULA = re.compile(r"([0-9.]+)\s*%") + + +@dataclass(frozen=True) +class StatutoryItem: + """법정경비 한 비목의 정의 — 이름·요율 변수·밑수 뽑는 법.""" + + key: str + name: str + rate_variable: str + base_label: str + + +#: 비목 정의. **순서가 곧 원가계산서 줄 순서**다. +STATUTORY_ITEMS: tuple[StatutoryItem, ...] = ( + StatutoryItem( + "industrial_accident_insurance", "산재보험료", "rate_sanjae", "노무비(직접+간접)" + ), + StatutoryItem("employment_insurance", "고용보험료", "rate_goyong", "노무비(직접+간접)"), + StatutoryItem("health_insurance", "국민건강보험료", "rate_health", "직접노무비"), + StatutoryItem("long_term_care_insurance", "노인장기요양보험료", "rate_care", "국민건강보험료"), + StatutoryItem("national_pension", "국민연금보험료", "rate_pension", "직접노무비"), + StatutoryItem( + "safety_management_cost", "산업안전보건관리비", "rate_safety_pct", "A·B 중 작은 금액" + ), + StatutoryItem("other_expense", "기타경비", "rate_other_expense", "재료비+노무비(직접+간접)"), + StatutoryItem("environment_preservation", "환경보전비", "rate_environment", "직접공사비"), + StatutoryItem( + "retirement_mutual_aid", "퇴직공제부금비", "rate_retirement_mutual_aid", "직접노무비" + ), + StatutoryItem( + "wage_claim_contribution", + "임금채권보장기금 부담금", + "rate_wage_claim_contribution", + "노무비(직접+간접)", + ), + StatutoryItem( + "asbestos_contribution", + "석면피해구제 분담금", + "rate_asbestos_contribution", + "노무비(직접+간접)", + ), + StatutoryItem( + "equipment_payment_guarantee", + "건설기계대여대금 지급보증수수료", + "rate_equipment_payment_guarantee", + "직접공사비", + ), + StatutoryItem( + "subcontract_payment_guarantee", + "하도급대금 지급보증수수료", + "rate_subcontract_payment_guarantee", + "직접공사비", + ), + StatutoryItem( + "performance_guarantee_fee", + "공사이행보증수수료", + "rate_performance_guarantee_fee", + "직접공사비 × 공사기간(년)", + ), +) + +_ITEM_BY_KEY = {item.key: item for item in STATUTORY_ITEMS} + + +def available_items(dataset: RateDataset) -> tuple[str, ...]: + """**그 해에 유효한 비목 목록** — 요율 데이터에 그 변수가 있는 것만 (PLAN 8-13). + + 비목 목록을 코드에 박지 않기 위한 자리. 연도별 요율 파일이 갈리면 목록도 따라 갈린다. + """ + return tuple(item.key for item in STATUTORY_ITEMS if item.rate_variable in dataset.variables) + + +def item_name(key: str) -> str: + return _ITEM_BY_KEY[key].name + + +@dataclass +class ExpenseContext: + """법정경비 계산에 필요한 밑수 묶음 — 엔진이 채워 넘긴다.""" + + material_cost: Decimal + direct_labor_cost: Decimal + total_labor_cost: Decimal + direct_construction_cost: Decimal + scale_reference: Decimal + + +def _threshold_met(dataset: RateDataset, variable: str, field: str, amount: Decimal) -> bool: + """적용 하한(추정금액 1억 이상 등)을 넘겼는가.""" + minimum = dataset.variable(variable).get(field) + if minimum is None: + return True + return amount >= Decimal(str(minimum)) + + +def _guarantee_percent_from_formula(row: dict[str, Any], *, label: str) -> Decimal: + """공사이행보증수수료는 요율 데이터가 값이 아니라 **식 문자열**을 들고 있다. + + 예: `"(direct_cost * 0.0108%) * duration_years"` → 0.0108 을 뽑는다. + 식 모양이 바뀌면 조용히 넘기지 않고 멈춘다. + """ + formula = str(row.get("formula", "")) + match = _RE_GUARANTEE_FORMULA.search(formula) + if not match: + raise RateLookupError(f"{label}: 요율 식에서 백분율을 못 읽었습니다 — {formula!r}") + return Decimal(match.group(1)) + + +def safety_management_cost( + dataset: RateDataset, + data: CostInput, + ctx: ExpenseContext, + emit: Callable[..., Decimal], +) -> Decimal: + """산업안전보건관리비 — A·B 두 값을 다 내고 **작은 쪽**을 채택한다. + + A) (재료비 + 직접노무비 + 도급자설치 관급금액) × 요율 + 기초액 + B) ((재료비 + 직접노무비) × 요율 + 기초액) × 1.2 + + 두 대상액이 **다른 구간에 떨어질 수 있어** A 가 항상 작지는 않다. 두 줄을 다 남겨 + 화면이 나란히 보이게 한다(실무 `안전관리비검토` 시트와 같은 서식). + """ + variable = dataset.variable("rate_safety_pct") + brackets = variable["brackets"] + + owner_supplied = data.owner_supplied_for_safety_krw + if owner_supplied is None: + owner_supplied = data.owner_supplied_material_krw + if data.owner_supplied_includes_vat: + owner_supplied = owner_supplied / _VAT_DIVISOR + + base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied + base_without = ctx.material_cost + ctx.direct_labor_cost + + def evaluate( + base: Decimal, multiplier: Decimal, label: str + ) -> tuple[Decimal, Decimal, Decimal]: + row = select_bracket( + brackets, + amount_field="target_amount_bracket", + amount=base, + equals={"work_type": data.work_type_safety}, + label=label, + ) + percent = rate_percent(row, label=label) + flat = base_amount(row) + return (base * percent / _HUNDRED + flat) * multiplier, percent, flat + + raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)") + raw_b, percent_b, flat_b = evaluate( + base_without, _SAFETY_B_MULTIPLIER, "안전관리비 B(관급 제외 × 1.2)" + ) + + # 어느 쪽이 채택인지 먼저 정해 두 줄에 표시를 단다 — 화면이 나란히 보이고 + # 채택 줄이 눈에 띄어야 한다(PLAN 8-12 실무 `안전관리비검토` 시트 서식). + from B09_Estimation.B09_Estimation_Engine_Cost import floor_won + + adopted = "A" if floor_won(raw_a) <= floor_won(raw_b) else "B" + + amount_a = emit( + key="safety_management_cost_a", + name="산업안전보건관리비 A(관급 포함)", + base_label="재료비+직접노무비+도급자설치 관급금액(부가세 제외)", + base=base_with, + percent=percent_a, + flat=flat_a, + raw=raw_a, + note="채택" if adopted == "A" else "미채택", + ) + amount_b = emit( + key="safety_management_cost_b", + name="산업안전보건관리비 B(관급 제외 × 1.2)", + base_label="재료비+직접노무비 (관급 제외)", + base=base_without, + percent=percent_b, + flat=flat_b, + raw=raw_b, + note="채택" if adopted == "B" else "미채택", + ) + return emit( + key="safety_management_cost", + name="산업안전보건관리비", + base_label=f"A·B 중 작은 금액 (채택 = {adopted})", + base=base_with if adopted == "A" else base_without, + percent=percent_a if adopted == "A" else percent_b, + flat=_ZERO, + raw=min(amount_a, amount_b), + note="고용노동부 고시 제2025-11호 — 둘 중 작은 금액", + ) + + +def statutory_expenses( + dataset: RateDataset, + data: CostInput, + ctx: ExpenseContext, + result: CostResult, + emit: Callable[..., Decimal], +) -> Decimal: + """켜진 비목만 순서대로 계산해 합계를 돌려준다.""" + enabled = [key for key in data.enabled_items if key in _ITEM_BY_KEY] + unknown = [key for key in data.enabled_items if key not in _ITEM_BY_KEY] + if unknown: + raise RateLookupError(f"모르는 비목입니다: {unknown}") + + missing = [k for k in enabled if _ITEM_BY_KEY[k].rate_variable not in dataset.variables] + if missing: + raise RateLookupError( + f"이 요율 판({dataset.effective_date})에 없는 비목입니다: " + f"{[item_name(k) for k in missing]}" + ) + + total = _ZERO + health_amount = _ZERO + + for key in enabled: + item = _ITEM_BY_KEY[key] + + if key == "safety_management_cost": + total += safety_management_cost(dataset, data, ctx, emit) + continue + + if key == "long_term_care_insurance": + if "health_insurance" not in enabled: + raise RateLookupError( + "노인장기요양보험료는 건강보험료를 밑수로 씁니다 — 건강보험료를 켜야 합니다" + ) + total += emit( + key=key, + name=item.name, + base_label=item.base_label, + base=health_amount, + percent=Decimal(str(dataset.variable(item.rate_variable)["rate_percent"])), + ) + continue + + base, percent, note = _base_and_rate(dataset, data, ctx, item) + if base is None: + continue # 적용 하한 미달 — 줄 자체를 만들지 않는다 + + amount = emit( + key=key, + name=item.name, + base_label=item.base_label, + base=base, + percent=percent, + note=note, + ) + if key == "health_insurance": + health_amount = amount + total += amount + + return total + + +def _base_and_rate( + dataset: RateDataset, + data: CostInput, + ctx: ExpenseContext, + item: StatutoryItem, +) -> tuple[Decimal | None, Decimal, str]: + """비목별 밑수·요율. 밑수가 `None` 이면 적용 대상이 아니다.""" + variable = dataset.variable(item.rate_variable) + key = item.key + + if key in ("industrial_accident_insurance", "wage_claim_contribution", "asbestos_contribution"): + return ctx.total_labor_cost, Decimal(str(variable["rate_percent"])), "" + + if key == "employment_insurance": + # 등급이 추정가격으로 갈린다. 임도는 대개 고시 기준금액 미만이라 숫자 구간에 안 걸리므로 + # 잔여 구간을 이름으로 지정한다(등급 7·그 이하 모두 같은 요율). + row = select_bracket( + variable["brackets"], + amount_field="estimated_amount_bracket", + amount=ctx.scale_reference, + residual_label="below_official_threshold", + label=item.name, + ) + return ctx.total_labor_cost, rate_percent(row, label=item.name), "" + + if key == "health_insurance": + return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" + + if key == "national_pension": + return ctx.direct_labor_cost, pension_rate_percent(dataset, data.pension_year), "" + + if key == "other_expense": + row = select_bracket( + variable["brackets"], + amount_field="direct_cost_bracket", + amount=ctx.direct_construction_cost, + duration_days=data.duration_days, + equals={"work_type": data.work_type_indirect_labor}, + label=item.name, + ) + return ctx.material_cost + ctx.total_labor_cost, rate_percent(row, label=item.name), "" + + if key == "environment_preservation": + if not _threshold_met( + dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference + ): + return None, _ZERO, "" + row = next( + ( + r + for r in variable["all_work_types"] + if r.get("work_type") == data.environment_work_type + ), + None, + ) + if row is None: + raise RateLookupError( + f"환경보전비: 공종을 못 찾았습니다 — {data.environment_work_type}" + ) + note = "" + if variable.get("forest_road_selection_status") == "pending": + note = "⚠ 임도 공종 채택값 미확정 (지식DB `forest_road_selection_status: pending`)" + return ctx.direct_construction_cost, rate_percent(row, label=item.name), note + + if key == "retirement_mutual_aid": + if not _threshold_met( + dataset, item.rate_variable, "minimum_estimated_amount_krw", ctx.scale_reference + ): + return None, _ZERO, "" + return ctx.direct_labor_cost, Decimal(str(variable["rate_percent"])), "" + + if key == "equipment_payment_guarantee": + rows = variable["general_construction"] + variable["specialty_construction"] + row = next( + (r for r in rows if r.get("work_type") == data.equipment_guarantee_work_type), None + ) + if row is None: + raise RateLookupError( + "건설기계대여대금 지급보증: 공종을 못 찾았습니다 — " + f"{data.equipment_guarantee_work_type}" + ) + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" + + if key == "subcontract_payment_guarantee": + # 30억 이상 구간은 공종(토목·산업설비 / 건축)으로 한 번 더 갈린다. + row = select_bracket( + variable["brackets"], + amount_field="estimated_price_bracket", + amount=ctx.scale_reference, + prefer_suffix=data.subcontract_guarantee_variant, + label=item.name, + ) + return ctx.direct_construction_cost, rate_percent(row, label=item.name), "" + + if key == "performance_guarantee_fee": + row = select_bracket( + variable["brackets"], + amount_field="direct_cost_bracket", + amount=ctx.direct_construction_cost, + label=item.name, + ) + percent = _guarantee_percent_from_formula(row, label=item.name) + years = Decimal(str(data.duration_days)) / Decimal(365) + note = variable.get("typical_forest_road_applicability", "") + return ctx.direct_construction_cost * years, percent, f"임도 적용성: {note}" if note else "" + + raise RateLookupError(f"밑수 정의가 없는 비목입니다: {key}") diff --git a/B09_Estimation/B09_Estimation_Storage.py b/B09_Estimation/B09_Estimation_Storage.py new file mode 100644 index 00000000..70b18a9c --- /dev/null +++ b/B09_Estimation/B09_Estimation_Storage.py @@ -0,0 +1,215 @@ +"""B09 원가계산 — 프로젝트 저장 (PLAN 9-2). + +**기준자료는 파일, 프로젝트가 채택한 단가는 사본**이다 (사용자 확정 2026-09-07). + + - 기준자료(요율·품셈·노임·자재) = `resources/data_cost_input_value/` 의 버전 파일. + 갱신해도 **옛 프로젝트 결과가 안 바뀌어야** 한다. + - 그래서 프로젝트는 **그때 쓴 단가를 사본으로** 영구저장소 + `/B09_Estimation/v1/` 에 남긴다. 3년 뒤 열어도 같은 금액이 나온다. + 상용 프로그램도 같은 방식이다 — STmate 는 단가표(`COSTN`)·제비율표(`RATE`)를 + **프로젝트 파일 안에 동봉**한다. + +`dataset_version` 은 **세 쪽**을 다 적는다 — `dataset_id` · `effective_date` · `sha256`. +파일명만 적으면 같은 날짜로 재생성된 파일과 구분이 안 된다. + +⚠ **차수 슬롯** — 실무 STC 는 단가를 `TAMT1~4`(당초·1차·2차·3차 변경) 네 벌로 들고 +있고, 파일마다 살아 있는 슬롯이 다르다(계류보전 변경본은 3차). 지금 신설 설계에는 +안 걸리지만 저장 구조를 정하는 중이라 **차수 자리를 비워 둔다**. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import ( + DEFAULT_SLOT_NAMES, + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) + +#: 영구저장소 안 B09 몫. `common_util_storage.PROJECT_STORAGE_LAYOUT_V2` 와 같은 자리. +SNAPSHOT_SUBPATH = ("B09_Estimation", "v1") +SNAPSHOT_FILE = "price_book.json" +MANIFEST_FILE = "_manifest.json" +SNAPSHOT_SCHEMA_VERSION = "1.0" + +#: 차수 — 당초(1)만 쓴다. 변경설계가 붙으면 2·3·4 로 늘어난다. +DEFAULT_REVISION = 1 + + +class SnapshotError(OSError): + """스냅샷 읽기·쓰기 실패.""" + + +@dataclass(frozen=True) +class DatasetVersion: + """어느 판 기준자료로 계산했나 — 세 쪽을 다 적는다.""" + + dataset_id: str + effective_date: str + sha256: str + + def as_dict(self) -> dict[str, str]: + return { + "dataset_id": self.dataset_id, + "effective_date": self.effective_date, + "sha256": self.sha256, + } + + @classmethod + def from_dict(cls, raw: dict[str, str]) -> DatasetVersion: + return cls( + dataset_id=raw.get("dataset_id", ""), + effective_date=raw.get("effective_date", ""), + sha256=raw.get("sha256", ""), + ) + + +def snapshot_dir(project_root: str) -> str: + path = os.path.abspath(os.path.join(project_root, *SNAPSHOT_SUBPATH)) + root = os.path.abspath(project_root) + if os.path.commonpath((root, path)) != root: + raise SnapshotError("스냅샷 경로가 프로젝트 루트를 벗어났습니다.") + return path + + +def _sha256_of(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _title_to_dict(title: PriceTitle) -> dict[str, Any]: + return { + "code": title.code, + "kind": title.kind.value, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "slots": [None if v is None else str(v) for v in title.slots], + "slot_pages": list(title.slot_pages), + "adopted_slot": title.adopted_slot, + } + + +def _title_from_dict(raw: dict[str, Any]) -> PriceTitle: + return PriceTitle( + code=raw["code"], + kind=PriceKind(raw["kind"]), + name=raw.get("name", ""), + spec=raw.get("spec", ""), + unit=raw.get("unit", ""), + slots=[None if v is None else Decimal(str(v)) for v in raw.get("slots", [])], + slot_pages=list(raw.get("slot_pages", [])), + adopted_slot=int(raw.get("adopted_slot", 6)), + ) + + +def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]: + return { + "parent_code": detail.parent_code, + "ref_code": detail.ref_code, + "quantity": str(detail.quantity), + "note": detail.note, + "percent_of_parent": ( + None if detail.percent_of_parent is None else str(detail.percent_of_parent) + ), + } + + +def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail: + percent = raw.get("percent_of_parent") + return PriceDetail( + parent_code=raw["parent_code"], + ref_code=raw["ref_code"], + quantity=Decimal(str(raw.get("quantity", "0"))), + note=raw.get("note", ""), + percent_of_parent=None if percent is None else Decimal(str(percent)), + ) + + +def save_price_book( + project_root: str, + book: PriceBook, + *, + dataset_versions: list[DatasetVersion], + revision: int = DEFAULT_REVISION, +) -> str: + """프로젝트가 채택한 단가를 사본으로 남긴다. 저장한 파일 경로를 돌려준다. + + 기준자료가 갱신돼도 이 사본이 있어 **옛 프로젝트 결과가 안 바뀐다**. + """ + directory = snapshot_dir(project_root) + os.makedirs(directory, exist_ok=True) + + payload = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "revision": revision, + "slot_names": list(book.slot_names), + "titles": [_title_to_dict(t) for t in book.titles.values()], + "details": [_detail_to_dict(d) for rows in book.details.values() for d in rows], + } + text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + + path = os.path.join(directory, SNAPSHOT_FILE) + with open(path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + + manifest = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "revision": revision, + "files": [ + { + "file": SNAPSHOT_FILE, + "sha256": _sha256_of(text), + "size_bytes": len(text.encode("utf-8")), + } + ], + # 어느 판 기준자료로 계산했나 — 세 쪽을 다 적는다(9-2). + "dataset_versions": [v.as_dict() for v in dataset_versions], + } + with open( + os.path.join(directory, MANIFEST_FILE), "w", encoding="utf-8", newline="\n" + ) as handle: + json.dump(manifest, handle, ensure_ascii=False, indent=2, sort_keys=True) + + return path + + +def load_price_book(project_root: str) -> tuple[PriceBook, list[DatasetVersion]]: + """사본을 그대로 되읽는다. 파일이 손상됐으면 조용히 넘기지 않고 멈춘다.""" + directory = snapshot_dir(project_root) + path = os.path.join(directory, SNAPSHOT_FILE) + if not os.path.exists(path): + raise SnapshotError(f"단가 스냅샷이 없습니다: {path}") + + with open(path, encoding="utf-8") as handle: + text = handle.read() + payload = json.loads(text) + + manifest_path = os.path.join(directory, MANIFEST_FILE) + versions: list[DatasetVersion] = [] + if os.path.exists(manifest_path): + with open(manifest_path, encoding="utf-8") as handle: + manifest = json.load(handle) + recorded = next( + (f.get("sha256") for f in manifest.get("files", []) if f.get("file") == SNAPSHOT_FILE), + None, + ) + if recorded and recorded != _sha256_of(text): + raise SnapshotError( + f"단가 스냅샷 지문이 매니페스트와 다릅니다 — 파일이 바뀌었습니다: {path}" + ) + versions = [DatasetVersion.from_dict(v) for v in manifest.get("dataset_versions", [])] + + book = PriceBook(slot_names=tuple(payload.get("slot_names", DEFAULT_SLOT_NAMES))) + for raw in payload.get("titles", []): + book.add_title(_title_from_dict(raw)) + for raw in payload.get("details", []): + book.add_detail(_detail_from_dict(raw)) + return book, versions diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 879aef9b..700db411 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -1,26 +1,1217 @@ /* ============================================================================= * B09_Estimation_UI_Page.ts - * 로그인 후 09: 6차 워크플로우 (견적·문서) + * 로그인 후 09: 6차 워크플로우 (원가계산) * - * ⚠️ 본문 준비 중 — 워크플로우 셸(헤더+스텝바)만 구성. 추후 구체화. - * 제약 준수 (frontend.md §2 3단 레이아웃): createWorkflowLayout 재사용. + * 화면 규칙 (PLAN 8-13 · 화면 기획) + * - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표. + * - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다. + * 결과 숫자만 보이면 설계자가 검산을 못 한다. + * - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다 + * (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12). + * - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2). + * - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을 + * 보여만 준다 (★법대로 PLAN 8-10). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { renderPendingWorkflow, workflowSteps } from "../A00_Common/b_page_scaffold"; +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; -/** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/* ----------------------------------------------------------------------------- + * 타입 — 라우터 응답과 1:1 + * -------------------------------------------------------------------------- */ + +interface CostLineDto { + key: string; + name: string; + base_label: string; + base_amount_krw: string; + rate_percent: string | null; + flat_amount_krw: string; + amount_krw: string; + formula_text: string; + note: string; +} + +interface CostSheetDto { + status: string; + direct_cost_source: "manual" | "quantities"; + missing_unit_prices: string[]; + lines: CostLineDto[]; + totals: Record; + rate_version: { dataset_id: string; effective_date: string; sha256: string }; + notes: string[]; + suggested_profit_adjustment_krw?: string; +} + +interface UnitPriceRow { + code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; +} + +interface UnitPriceListDto { + status: string; + summary: { + titles: number; + unit_prices: number; + machine_hourly: number; + notes: string[]; + }; + rows: UnitPriceRow[]; +} + +interface UnitPriceDetailRow extends UnitPriceRow { + ref_code: string; + source_label: string; + source_index: number; + drillable: boolean; + quantity: string; + unit_total: string; + note: string; +} + +interface UnitPriceDetailDto { + status: string; + precise_total: string; + code: string; + name: string; + spec: string; + unit: string; + material: string; + labor: string; + expense: string; + total: string; + sum_matches: boolean; + rows: UnitPriceDetailRow[]; +} + +/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ +interface CostFormState { + direct_material_krw: string; + direct_labor_krw: string; + direct_expense_krw: string; + duration_days: string; + owner_supplied_material_krw: string; + procurement_fee_krw: string; + profit_adjustment_krw: string; + target_contract_amount_krw: string; + /** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */ + quantities_text: string; +} + +const INITIAL_FORM: CostFormState = { + direct_material_krw: "0", + direct_labor_krw: "0", + direct_expense_krw: "0", + duration_days: "183", + owner_supplied_material_krw: "0", + procurement_fee_krw: "0", + profit_adjustment_krw: "0", + target_contract_amount_krw: "", + quantities_text: "", +}; + +/** 총계 성격의 줄 — 표에서 굵게 띄운다. */ +const TOTAL_KEYS = new Set([ + "material_cost", + "labor_cost", + "expense", + "net_construction_cost", + "total_cost", + "contract_amount", + "grand_total", +]); + +/* ----------------------------------------------------------------------------- + * 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지) + * -------------------------------------------------------------------------- */ + +const STYLE_ID = "b09-estimation-styles"; + +function injectStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` +.b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } +.b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } +.b09-panel__legend { + font-size: var(--font-size-xs, 12px); letter-spacing: .06em; + color: var(--color-text-secondary); text-transform: uppercase; +} +.b09-panel__readonly { + font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); + display: flex; justify-content: space-between; gap: var(--space-sm, 8px); + border-bottom: 1px solid var(--color-border); padding: 2px 0; +} +.b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } +.b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } + +.b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; } +.b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } +.b09-tab { + font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; + border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); +} +.b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } +.b09-tab:disabled { cursor: not-allowed; opacity: .55; } + +.b09-sheet { overflow: auto; min-height: 0; flex: 1; } +.b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } +.b09-sheet th, .b09-sheet td { + border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; + white-space: nowrap; font-variant-numeric: tabular-nums; +} +.b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; } +.b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; } +.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } +.b09-sheet tr.is-adopted td { background: var(--color-surface); } +.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } +.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); } +.b09-clickable { cursor: pointer; } +.b09-clickable:hover td { background: var(--color-surface); } +.b09-up-list { max-height: 45%; } +.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; } +.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } +`; + document.head.append(style); +} + +/* ----------------------------------------------------------------------------- + * 표 그리기 + * -------------------------------------------------------------------------- */ + +function formatWon(value: string): string { + const n = Number(value); + if (!Number.isFinite(n)) return value; + return n.toLocaleString("ko-KR"); +} + +function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet"; + + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + const headers: Array<[string, boolean]> = [ + [L("B09_Estimation_Col_Item"), true], + [L("B09_Estimation_Col_Amount"), false], + [L("B09_Estimation_Col_Rate"), false], + [L("B09_Estimation_Col_Basis"), true], + [L("B09_Estimation_Col_Note"), true], + ]; + for (const [text, left] of headers) { + const th = document.createElement("th"); + th.textContent = text; + if (left) th.className = "b09-left"; + headRow.append(th); + } + thead.append(headRow); + table.append(thead); + + const tbody = document.createElement("tbody"); + for (const line of sheet.lines) { + const tr = document.createElement("tr"); + if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total"); + if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted"); + if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped"); + + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = line.name; + + const amount = document.createElement("td"); + amount.textContent = formatWon(line.amount_krw); + + const rate = document.createElement("td"); + rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; + + const basis = document.createElement("td"); + basis.className = "b09-left"; + basis.textContent = line.formula_text; + + const note = document.createElement("td"); + note.className = "b09-left"; + note.textContent = line.note; + + tr.append(name, amount, rate, basis, note); + tbody.append(tr); + } + table.append(tbody); + wrap.append(table); + return wrap; +} + +/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */ +function buildUnitPriceList( + list: UnitPriceListDto, + selected: string | null, + onPick: (code: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet b09-up-list"; + + const caption = document.createElement("div"); + caption.className = "b09-hint"; + caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`; + wrap.append(caption); + + const table = document.createElement("table"); + const head = document.createElement("tr"); + for (const [key, left] of [ + ["B09_Estimation_Col_Name", true], + ["B09_Estimation_Col_Unit", true], + ["B09_Estimation_Col_Material", false], + ["B09_Estimation_Col_Labor", false], + ["B09_Estimation_Col_Expense", false], + ["B09_Estimation_Col_Total", false], + ] as Array<[keyof typeof ui_locales, boolean]>) { + const th = document.createElement("th"); + th.textContent = L(key); + if (left) th.className = "b09-left"; + head.append(th); + } + const thead = document.createElement("thead"); + thead.append(head); + table.append(thead); + + const body = document.createElement("tbody"); + for (const row of list.rows) { + const tr = document.createElement("tr"); + tr.className = "b09-clickable"; + if (row.code === selected) tr.classList.add("is-adopted"); + tr.addEventListener("click", () => onPick(row.code)); + + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = row.name; + const unit = document.createElement("td"); + unit.className = "b09-left"; + unit.textContent = row.unit; + tr.append(name, unit); + for (const value of [row.material, row.labor, row.expense, row.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + tr.append(cell); + } + body.append(tr); + } + table.append(body); + wrap.append(table); + return wrap; +} + +/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */ +function buildUnitPriceDetail( + detail: UnitPriceDetailDto, + onDrill: (code: string) => void, +): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b09-sheet b09-up-detail"; + + const caption = document.createElement("div"); + caption.className = "b09-hint"; + caption.textContent = + `${L("B09_Estimation_UP_Detail")} · ${detail.name}` + + (detail.spec ? ` (${detail.spec})` : "") + + ` · ${formatWon(detail.total)}` + + ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; + wrap.append(caption); + + // 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.** + // 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다. + if (detail.precise_total !== detail.total) { + const gap = document.createElement("div"); + gap.className = "b09-hint"; + gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`; + wrap.append(gap); + } + + const table = document.createElement("table"); + const head = document.createElement("tr"); + for (const [key, left] of [ + ["B09_Estimation_Col_Name", true], + ["B09_Estimation_Col_Spec", true], + ["B09_Estimation_Col_Source", true], + ["B09_Estimation_Col_Unit", true], + ["B09_Estimation_Col_Qty", false], + ["B09_Estimation_Col_Material", false], + ["B09_Estimation_Col_Labor", false], + ["B09_Estimation_Col_Expense", false], + ["B09_Estimation_Col_Total", false], + ] as Array<[keyof typeof ui_locales, boolean]>) { + const th = document.createElement("th"); + th.textContent = L(key); + if (left) th.className = "b09-left"; + head.append(th); + } + const thead = document.createElement("thead"); + thead.append(head); + table.append(thead); + + const body = document.createElement("tbody"); + for (const row of detail.rows) { + const tr = document.createElement("tr"); + if (row.drillable) { + tr.className = "b09-clickable"; + tr.title = L("B09_Estimation_UP_Drill"); + tr.addEventListener("click", () => onDrill(row.ref_code)); + } + const name = document.createElement("td"); + name.className = "b09-left"; + name.textContent = row.drillable ? `▸ ${row.name}` : row.name; + const spec = document.createElement("td"); + spec.className = "b09-left"; + spec.textContent = row.spec; + const source = document.createElement("td"); + source.className = "b09-left"; + source.textContent = `${row.source_label} (${row.source_index})`; + const unit = document.createElement("td"); + unit.className = "b09-left"; + unit.textContent = row.unit; + tr.append(name, spec, source, unit); + for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + tr.append(cell); + } + body.append(tr); + } + + const sum = document.createElement("tr"); + sum.className = "is-total"; + const label = document.createElement("td"); + label.className = "b09-left"; + label.colSpan = 5; + label.textContent = L("B09_Estimation_Col_Total"); + sum.append(label); + for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { + const cell = document.createElement("td"); + cell.textContent = formatWon(value); + sum.append(cell); + } + body.append(sum); + + table.append(body); + wrap.append(table); + return wrap; +} + +/* ----------------------------------------------------------------------------- + * 좌측 패널 + * -------------------------------------------------------------------------- */ + +interface PanelHandles { + root: HTMLElement; + rateVersionBox: HTMLElement; + hintBox: HTMLElement; +} + +function buildSidePanel( + form: CostFormState, + onRecalc: () => void, + onConfirm: () => void, +): PanelHandles { + const root = document.createElement("div"); + root.className = "b09-panel"; + + const addGroup = ( + legendKey: keyof typeof ui_locales, + fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, + ): void => { + const group = document.createElement("div"); + group.className = "b09-panel__group"; + const legend = document.createElement("span"); + legend.className = "b09-panel__legend"; + legend.textContent = L(legendKey); + group.append(legend); + for (const [field, labelKey] of fields) { + const handle = createInputField({ + label: L(labelKey), + type: "number", + min: 0, + value: form[field], + onInput: (value) => { + form[field] = value; + }, + }); + group.append(handle.root); + } + root.append(group); + }; + + addGroup("B09_Estimation_Group_Condition", [ + ["direct_material_krw", "B09_Estimation_Field_DirectMaterial"], + ["direct_labor_krw", "B09_Estimation_Field_DirectLabor"], + ["direct_expense_krw", "B09_Estimation_Field_DirectExpense"], + ["duration_days", "B09_Estimation_Field_Duration"], + ]); + + // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. + const rateGroup = document.createElement("div"); + rateGroup.className = "b09-panel__group"; + const rateLegend = document.createElement("span"); + rateLegend.className = "b09-panel__legend"; + rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); + const rateVersionBox = document.createElement("div"); + rateGroup.append(rateLegend, rateVersionBox); + root.append(rateGroup); + + addGroup("B09_Estimation_Group_Supplied", [ + ["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"], + ["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"], + ]); + + addGroup("B09_Estimation_Group_Profit", [ + ["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"], + ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], + ]); + + // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. + const quantityGroup = document.createElement("div"); + quantityGroup.className = "b09-panel__group"; + const quantityLegend = document.createElement("span"); + quantityLegend.className = "b09-panel__legend"; + quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); + const quantityLabel = document.createElement("label"); + quantityLabel.className = "ui-field__label"; + quantityLabel.textContent = L("B09_Estimation_Field_Quantities"); + const quantityInput = document.createElement("textarea"); + quantityInput.className = "ui-input b09-qty"; + quantityInput.rows = 4; + quantityInput.placeholder = "FP-09-21=500"; + quantityInput.addEventListener("input", () => { + form.quantities_text = quantityInput.value; + }); + quantityGroup.append(quantityLegend, quantityLabel, quantityInput); + root.append(quantityGroup); + + const hintBox = document.createElement("div"); + hintBox.className = "b09-hint"; + root.append(hintBox); + + const actions = document.createElement("div"); + actions.className = "b09-panel__actions"; + actions.append( + createButton({ + label: L("B09_Estimation_Btn_Recalc"), + variant: "filled", + onClick: onRecalc, + }), + createButton({ + label: L("B09_Estimation_Btn_Confirm"), + onClick: onConfirm, + }), + ); + root.append(actions); + + return { root, rateVersionBox, hintBox }; +} + +function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { + box.replaceChildren(); + if (!sheet) return; + const rows: Array<[string, string]> = [ + ["적용일", sheet.rate_version.effective_date || "—"], + ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], + ]; + for (const [label, value] of rows) { + const row = document.createElement("div"); + row.className = "b09-panel__readonly"; + const left = document.createElement("span"); + left.textContent = label; + const right = document.createElement("span"); + right.textContent = value; + row.append(left, right); + box.append(row); + } +} + +/* ----------------------------------------------------------------------------- + * 탭 + * -------------------------------------------------------------------------- */ + +const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ + ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], + ["boq", "B09_Estimation_Tab_Boq", true], + ["unit_price", "B09_Estimation_Tab_UnitPrice", true], + ["price_basis", "B09_Estimation_Tab_PriceBasis", true], + ["machine", "B09_Estimation_Tab_Machine", false], + ["duration", "B09_Estimation_Tab_Duration", false], + ["supply", "B09_Estimation_Tab_Supply", true], + ["base_data", "B09_Estimation_Tab_BaseData", false], +]; + +function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { + const bar = document.createElement("div"); + bar.className = "b09-tabs"; + for (const [key, labelKey, enabled] of TAB_KEYS) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b09-tab"; + button.dataset.tab = key; + button.textContent = L(labelKey); + button.disabled = !enabled; + if (!enabled) button.title = L("B09_Estimation_Tab_Pending"); + if (key === active) button.classList.add("is-active"); + button.addEventListener("click", () => onSelect(key)); + bar.append(button); + } + return bar; +} + +/* ----------------------------------------------------------------------------- + * API + * -------------------------------------------------------------------------- */ + +/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */ +function parseQuantities(text: string): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [code, value] = trimmed.split(/[=\t,]/); + if (!code || !value) continue; + const qty = value.trim(); + if (!/^\d+(\.\d+)?$/.test(qty)) continue; + out[code.trim()] = qty; + } + return out; +} + +function toRequestBody(form: CostFormState): Record { + const num = (value: string): string => (value.trim() === "" ? "0" : value.trim()); + const body: Record = { + direct_material_krw: num(form.direct_material_krw), + direct_labor_krw: num(form.direct_labor_krw), + direct_expense_krw: num(form.direct_expense_krw), + duration_days: Number(num(form.duration_days)), + owner_supplied_material_krw: num(form.owner_supplied_material_krw), + procurement_fee_krw: num(form.procurement_fee_krw), + profit_adjustment_krw: num(form.profit_adjustment_krw), + }; + if (form.target_contract_amount_krw.trim() !== "") { + body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); + } + const quantities = parseQuantities(form.quantities_text); + if (Object.keys(quantities).length > 0) body.quantities = quantities; + return body; +} + +async function fetchCostSheet(projectId: string, form: CostFormState): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toRequestBody(form)), + }, + ); + if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`); + return (await response.json()) as CostSheetDto; +} + +async function fetchUnitPriceList(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); + return (await response.json()) as UnitPriceListDto; +} + +async function fetchUnitPriceDetail(projectId: string, code: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); + return (await response.json()) as UnitPriceDetailDto; +} + +/** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */ +interface BillRowDto { + item_no: string; + level: number; + code: string | null; + name: string; + spec: string; + unit: string; + quantity: string | null; + /** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */ + quantity_shown: string | null; + quantity_digits: number | null; + unit_price_krw: string | null; + amount_krw: string | null; + is_group: boolean; + in_bill: boolean; + note: string; +} + +interface PriceBasisEntryDto { + number: number; + label: string; + code: string; + name: string; + spec: string; + unit: string; + unit_price_krw: string; + ref_code: string; +} + +interface BillDto { + rows: BillRowDto[]; + excluded: BillRowDto[]; + materials: BillRowDto[]; + summary: { + rows: number; + detail_rows: number; + body_total_krw: string; + missing: Array<{ + name: string; + reason: string; + unit?: string; + quantity?: string; + blocked_kind?: string; + }>; + notes: string[]; + material_sheet: MaterialSheetDto | null; + }; + price_basis: { entries: PriceBasisEntryDto[] }; +} + +interface MaterialSheetRowDto { + name: string; + spec: string; + unit: string; + total_amount: string; + unit_price_krw: string | null; + amount_krw: string | null; + note: string; +} + +interface MaterialSheetDto { + contractor: MaterialSheetRowDto[]; + owner: MaterialSheetRowDto[]; + unknown: MaterialSheetRowDto[]; + contractor_total_krw: string; + owner_total_krw: string; + missing: Array<{ name: string; reason: string }>; + notes: string[]; +} + +/** + * 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1). + * + * 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다 + * `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다. + * ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을 + * 아는 척 자르지 않는다. + * + * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 + * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. + */ +function formatQuantity(value: string | null, digits: number | null = null): string { + if (value === null || value === "") return ""; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return value; + const places = digits ?? 2; + return parsed.toLocaleString("ko-KR", { + minimumFractionDigits: places, + maximumFractionDigits: places, + }); +} + +async function fetchBill(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`); + return (await response.json()) as BillDto; +} + +async function confirmEstimationStage(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, + { method: "POST", credentials: "include" }, + ); + if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`); +} + /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ + export async function renderB09Estimation(root: HTMLElement): Promise { - await renderPendingWorkflow(root, { + injectStyles(); + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const form: CostFormState = { ...INITIAL_FORM }; + let activeTab = "cost_sheet"; + let sheet: CostSheetDto | null = null; + let unitPriceList: UnitPriceListDto | null = null; + let unitPriceDetail: UnitPriceDetailDto | null = null; + let selectedUnitPrice: string | null = null; + let bill: BillDto | null = null; + let priceBasis: string | null = null; + + const main = document.createElement("div"); + main.className = "b09-main"; + const body = document.createElement("div"); + body.style.flex = "1"; + body.style.minHeight = "0"; + body.style.display = "flex"; + body.style.flexDirection = "column"; + + /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ + const openUnitPrice = async (code: string): Promise => { + if (!projectId) return; + try { + unitPriceDetail = await fetchUnitPriceDetail(projectId, code); + selectedUnitPrice = code; + drawBody(); + } catch { + showToast(L("B09_Estimation_UP_Load_Failed"), "error"); + } + }; + + const drawUnitPriceTab = (): void => { + if (!unitPriceList) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Tab_Pending"); + body.append(empty); + return; + } + // 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다. + for (const note of unitPriceList.summary.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note; + body.append(line); + } + body.append( + buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => { + void openUnitPrice(code); + }), + ); + if (unitPriceDetail) { + body.append( + buildUnitPriceDetail(unitPriceDetail, (code) => { + void openUnitPrice(code); + }), + ); + } else { + const hint = document.createElement("div"); + hint.className = "b09-empty"; + hint.textContent = L("B09_Estimation_UP_Pick"); + body.append(hint); + } + }; + + /** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */ + const drawBoqTab = (): void => { + if (!bill) { + if (!projectId) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Boq_Failed"); + body.append(empty); + return; + } + const load = document.createElement("button"); + load.type = "button"; + load.className = "b09-btn"; + load.textContent = L("B09_Estimation_Boq_Load"); + load.addEventListener("click", () => { + void (async () => { + try { + bill = await fetchBill(projectId); + } catch { + bill = null; + window.alert(L("B09_Estimation_Boq_Failed")); + } + drawBody(); + })(); + }); + body.append(load); + return; + } + + const table = document.createElement("table"); + table.className = "b09-sheet"; + const head = document.createElement("thead"); + head.innerHTML = + "No.공종규격단위" + + "수량단가금액비고"; + const tbody = document.createElement("tbody"); + for (const row of bill.rows) { + const tr = document.createElement("tr"); + // 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다. + const indent = " ".repeat(Math.max(0, (row.level - 1) * 2)); + const cells = row.is_group + ? [row.item_no, indent + row.name, "", "", "", "", "", ""] + : [ + row.item_no, + indent + row.name, + row.spec, + row.unit, + formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits), + row.unit_price_krw ?? "", + row.amount_krw ?? "", + row.note, + ]; + for (const text of cells) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + if (row.is_group) tr.style.fontWeight = "600"; + tbody.append(tr); + } + table.append(head, tbody); + body.append(table); + + const total = document.createElement("div"); + total.className = "b09-hint"; + total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`; + body.append(total); + + // 표시 자릿수와 계산 자릿수가 다르다는 것을 숨기지 않는다. + const precision = document.createElement("div"); + precision.className = "b09-hint"; + precision.textContent = L("B09_Estimation_Boq_Precision"); + body.append(precision); + + // ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다. + const shortfall = document.createElement("div"); + shortfall.className = "b09-hint"; + shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice"); + body.append(shortfall); + + if (bill.excluded.length > 0) { + const note = document.createElement("div"); + note.className = "b09-hint"; + note.textContent = + `${L("B09_Estimation_Boq_Excluded")}: ` + + bill.excluded + .map( + (row) => + `${row.name} ${formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits)}${row.unit}`, + ) + .join(", "); + body.append(note); + } + + if (bill.summary.missing.length > 0) { + // ⚠ **머리글을 한 번만 단다.** 종전엔 「못 세운 줄 (16)」 뒤에 갈래별 머리글이 + // 또 붙어 **같은 수가 두 번** 떴다. 안내를 더하는 것이 곧 다른 안내를 묻는 것이라 + // (2026-09-08 메인 창 지적), 한 화면에 뜨는 줄 수를 늘리지 않는다. + // ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과 + // 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다. + // ⚠ **세 갈래로 가른다.** 「여기서 세지 않는 줄」을 할 일 목록에 얹으면 + // 사용자가 세우려 들고, 그것이 곧 이중계상이다(㉠~㉦ 규칙). + const notOurs = bill.summary.missing.filter((item) => item.blocked_kind === "not_our_row"); + const needsInput = bill.summary.missing.filter( + (item) => item.blocked_kind === "input_missing", + ); + const rest = bill.summary.missing.filter( + (item) => item.blocked_kind !== "input_missing" && item.blocked_kind !== "not_our_row", + ); + for (const [labelKey, group] of [ + ["B09_Estimation_Boq_NeedsInput", needsInput], + ["B09_Estimation_Boq_NeedsWork", rest], + ["B09_Estimation_Boq_NotOurs", notOurs], + ] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) { + if (group.length === 0) continue; + const head = document.createElement("div"); + head.className = "b09-hint"; + // 갈래가 하나뿐이면 「금액을 못 세운 줄」이라는 말을 앞에 붙여 뜻이 온전하게 한다. + const prefix = + needsInput.length > 0 && rest.length > 0 ? "" : `${L("B09_Estimation_Boq_Missing")} — `; + head.textContent = `${prefix}${L(labelKey)} (${group.length})`; + body.append(head); + const list = document.createElement("ul"); + for (const item of group) { + const li = document.createElement("li"); + li.textContent = `${item.name} — ${item.reason}`; + list.append(li); + } + body.append(list); + } + } + + if (bill.materials.length > 0) { + const note = document.createElement("div"); + note.className = "b09-hint"; + note.textContent = + `${L("B09_Estimation_Boq_Materials")}: ` + + bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", "); + body.append(note); + } + }; + + /** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */ + const drawPriceBasisTab = (): void => { + if (!bill) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_PB_Empty"); + body.append(empty); + return; + } + const entries = bill.price_basis?.entries ?? []; + // 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌 + // 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다). + const split = document.createElement("div"); + + const list = document.createElement("table"); + list.className = "b09-sheet b09-up-list"; + list.innerHTML = "번호공종단위단가"; + const tbody = document.createElement("tbody"); + for (const entry of entries) { + const tr = document.createElement("tr"); + for (const text of [ + String(entry.number), + `${entry.name} ${entry.spec}`.trim(), + entry.unit, + entry.unit_price_krw, + ]) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + tr.style.cursor = "pointer"; + if (entry.code === priceBasis) tr.style.fontWeight = "600"; + tr.addEventListener("click", () => { + priceBasis = entry.code; + drawBody(); + }); + tbody.append(tr); + } + list.append(tbody); + split.append(list); + + const picked = entries.find((entry) => entry.code === priceBasis) ?? null; + const panel = document.createElement("div"); + panel.className = "b09-up-detail"; + if (picked === null) { + panel.textContent = L("B09_Estimation_PB_Pick"); + } else { + const head = document.createElement("div"); + head.className = "b09-hint"; + head.textContent = `${picked.label} — ${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`; + const ref = document.createElement("div"); + ref.className = "b09-hint"; + // 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다. + ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`; + panel.append(head, ref); + } + split.append(panel); + body.append(split); + }; + + /** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */ + const drawMaterialTab = (): void => { + const sheet = bill?.summary.material_sheet ?? null; + if (!sheet) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Mat_Empty"); + body.append(empty); + return; + } + + // 자재가 아예 없으면 **빈 표 셋을 늘어놓지 않는다** — 「없다」 한 줄이면 된다 + // (2026-09-08 화면 전수에서 세 무리가 모두 「(0) — 0」 으로 뜨고 있었다). + if (sheet.contractor.length === 0 && sheet.owner.length === 0 && sheet.unknown.length === 0) { + const none = document.createElement("div"); + none.className = "b09-empty"; + none.textContent = L("B09_Estimation_Mat_None"); + body.append(none); + return; + } + + for (const [labelKey, rows, total] of [ + ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw], + ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw], + ["B09_Estimation_Mat_Unknown", sheet.unknown, null], + ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) { + const head = document.createElement("div"); + head.className = "b09-hint"; + head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`); + body.append(head); + if (rows.length === 0) continue; + + const table = document.createElement("table"); + table.className = "b09-sheet"; + table.innerHTML = + "자재규격단위수량" + + "단가금액비고"; + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + for (const text of [ + row.name, + row.spec, + row.unit, + formatQuantity(row.total_amount), + row.unit_price_krw ?? "", + row.amount_krw ?? "", + row.note, + ]) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + tbody.append(tr); + } + table.append(tbody); + body.append(table); + } + + for (const note of sheet.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note.replace(/\*\*/g, ""); + body.append(line); + } + }; + + const drawBody = (): void => { + body.replaceChildren(); + if (activeTab === "unit_price") { + drawUnitPriceTab(); + return; + } + if (activeTab === "boq") { + drawBoqTab(); + return; + } + if (activeTab === "price_basis") { + drawPriceBasisTab(); + return; + } + if (activeTab === "supply") { + drawMaterialTab(); + return; + } + if (activeTab !== "cost_sheet") { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Tab_Pending"); + body.append(empty); + return; + } + if (!sheet) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Btn_Recalc"); + body.append(empty); + return; + } + // 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다. + const source = document.createElement("div"); + source.className = "b09-hint"; + source.textContent = + sheet.direct_cost_source === "quantities" + ? L("B09_Estimation_Src_Quantities") + : L("B09_Estimation_Src_Manual"); + body.append(source); + + // 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**. + if (sheet.missing_unit_prices.length > 0) { + const missing = document.createElement("div"); + missing.className = "b09-hint"; + missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`; + body.append(missing); + } + + body.append(buildCostSheetTable(sheet)); + for (const note of sheet.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note; + body.append(line); + } + }; + + const drawTabs = (): void => { + const bar = buildTabs(activeTab, (key) => { + activeTab = key; + drawTabs(); + drawBody(); + if (key === "unit_price" && !unitPriceList && projectId) { + void fetchUnitPriceList(projectId) + .then((data) => { + unitPriceList = data; + drawBody(); + }) + .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); + } + }); + const old = main.querySelector(".b09-tabs"); + if (old) old.replaceWith(bar); + else main.prepend(bar); + }; + + const panel = buildSidePanel( + form, + async () => { + if (!projectId) return; + try { + sheet = await fetchCostSheet(projectId, form); + renderRateVersion(panel.rateVersionBox, sheet); + panel.hintBox.textContent = + sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" + ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` + : ""; + drawBody(); + } catch { + showToast(L("B09_Estimation_Calc_Failed"), "error"); + } + }, + async () => { + if (!projectId) return; + try { + await confirmEstimationStage(projectId); + showToast(L("B09_Estimation_Confirm_Success"), "success"); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]); + } catch { + showToast(L("B09_Estimation_Confirm_Failed"), "error"); + } + }, + ); + + main.append(body); + drawTabs(); + drawBody(); + + const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, + leftPanel: panel.root, + mainContent: main, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, }); + root.append(layout.root); } diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py new file mode 100644 index 00000000..46d2e785 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -0,0 +1,663 @@ +"""B09 원가계산 — ③ 단가산출·일위대가 조립 (PLAN 9-3 · 9-5). + +자원 축(`resource_axis`)이 「이 공종 1단위에 무엇이 얼마나」를 갖고 있고, 카탈로그가 +「그 자원 하나가 얼마」를 갖고 있다. 이 모듈이 둘을 곱해 **일위대가 한 줄**을 만든다. + +층은 그대로 쌓는다 (PLAN 9-3): + + S 취득가 · L 노임 · M 자재 → X 시간당 중기사용료 → B 일위대가 + +`PriceBook` 에 제목·상세로 앉히므로 **표를 따로 만들지 않는다.** + +**부르는 가드** (함수만 있고 안 부르면 없는 것과 같다) + - ㉠ 자재는 **할증 전** 값 — 할증은 자재총괄 한 곳뿐 (`check_surcharge_once`). + - ㉣ 작업효율은 사용료 쪽에 안 넣음 (`reject_efficiency_in_hourly_rate`, + `B09_Estimation_MachineCost` 안에서 호출됨). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from decimal import Decimal +from functools import lru_cache + +from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once +from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog +from B09_Estimation.B09_Estimation_MachineProductivity import ( + CycleFactors, + FactorGap, + attach_machine_share, + extract_cycle_factors, +) +from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import ( + attach_dozer_share, + dozer_variants, + extract_dozer_factors, + formula_machine_codes, +) +from B09_Estimation.B09_Estimation_MachineOperating import ( + load_fuel_price, + load_operating_records, + load_operator_wages, +) +from B09_Estimation.B09_Estimation_PriceBook import ( + PriceBook, + PriceDetail, + PriceKind, + PriceTitle, +) +from B09_Estimation.B09_Estimation_ResourceAxis import ( + RANGE_DASHES, + AxisResult, + build_resource_axis, + load_combined_catalog, + load_labor_catalog, + load_work_item_master, +) +from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + +_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") +_ZERO = Decimal(0) +#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다. +FUEL_CODE_PREFIX = "M-FUEL-" +#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다. +SUSPICIOUSLY_LOW_KRW = Decimal(100) +#: 기준 단위를 모르는 채 이 금액을 넘으면 **사람이 한 번 봐야 한다**. +#: 품셈 표가 「10㎡당」처럼 묶음 기준일 수 있어 값 자체는 맞고 기준만 모르는 경우가 많다 +#: (2026-09-08: 목재틀흙막이 상등구조 = 건축목공 16.975인 → 503만원. 값은 품셈대로다). +#: **막지 않고 드러내기만 한다** — 막으면 120 중 117 이 멈춘다. +SUSPICIOUSLY_HIGH_KRW = Decimal(1_000_000) + + +def _slots(value: Decimal) -> list[Decimal | None]: + """6번(적용 단가) 슬롯에만 값을 넣는다 — 유료 물가지 미구독 상태의 기본 모양.""" + slots: list[Decimal | None] = [None] * 6 + slots[5] = value + return slots + + +@dataclass +class UnitPriceBuild: + book: PriceBook = field(default_factory=PriceBook) + #: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다. + skipped: list[str] = field(default_factory=list) + #: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보). + incomplete_machines: list[str] = field(default_factory=list) + #: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구. + #: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져 + #: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구). + component_gaps: dict[str, str] = field(default_factory=dict) + #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). + partial_ratio: dict[str, Decimal] = field(default_factory=dict) + #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. + cycle_factors: dict[str, CycleFactors] = field(default_factory=dict) + #: 공종 하나가 낳은 규격 갈래들 — 「무근구조물」·「철근구조물」·「소형구조물」. + variants: dict[str, list[str]] = field(default_factory=dict) + #: 밑수(「10㎡당」)를 못 찾은 표를 쓰는 공종 — **곱하면 안 되는 줄**이다. + #: 1 단위당으로 단정하면 곱셈이 10배·100배 틀린다(B08 `basis_missing` 목록). + basis_missing: dict[str, str] = field(default_factory=dict) + #: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다. + factor_gaps: dict[str, FactorGap] = field(default_factory=dict) + + +def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None: + catalog = load_labor_catalog() + for entry in catalog.entries: + wage = wages.get(entry.code) + if wage is None or entry.code in book.titles: + continue + book.add_title( + PriceTitle( + code=entry.code, + kind=PriceKind.LABOR, + name=entry.name, + unit="인", + slots=_slots(wage), + ) + ) + + +def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: + """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. + + 시간당 사용료를 **미리 계산해 넣지 않는다** — 층을 실제로 쌓아야 화면이 + 「무엇으로 이루어졌나」를 보일 수 있다(PLAN 8-13 계산 과정을 감추지 않음). + """ + catalog = load_machine_catalog() + operating = {r.machine_code: r for r in load_operating_records().records} + fuel_price, _ = load_fuel_price() + wages = load_operator_wages() + incomplete: list[str] = [] + + fuel_code = f"{FUEL_CODE_PREFIX}경유" + if fuel_code not in book.titles: + book.add_title( + PriceTitle( + code=fuel_code, + kind=PriceKind.MATERIAL, + name="경유", + unit="L", + slots=_slots(fuel_price), + ) + ) + + for code in sorted(machine_codes): + machine = catalog.machines.get(code) + record = operating.get(code) + if machine is None or machine.loss_coefficient_per_hour is None or record is None: + incomplete.append(code) + continue + + base_code = f"S-{code}" + hourly_code = f"X-{code}" + if hourly_code in book.titles: + continue + + # S — 취득가에서 나온 시간당 손료. 경비 성분만 갖는다. + book.add_title( + PriceTitle( + code=base_code, + kind=PriceKind.MACHINE_BASE, + name=machine.name, + spec=machine.specification, + unit="hr", + slots=_slots( + machine.price_thousand_krw * Decimal(1000) * machine.loss_coefficient_per_hour + ), + ) + ) + book.add_title( + PriceTitle( + code=hourly_code, + kind=PriceKind.MACHINE_HOURLY, + name=machine.name, + spec=machine.specification, + unit="hr", + ) + ) + book.add_detail(PriceDetail(hourly_code, base_code, Decimal(1), note="시간당 손료")) + + liters = record.fuel_liters_per_hour + if liters is not None: + if record.misc_material_percent is not None: + # 잡재료는 **주연료의 %** — 유가와 같이 움직인다. + liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100)) + book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료")) + else: + incomplete.append(f"{code} (연료소모량 없음)") + + wage_code = record.operator_occupation_code + if wage_code and wage_code in wages and record.operator_person_days is not None: + # ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다. + per_hour_person = record.operator_person_days / Decimal(8) + if wage_code not in book.titles: + book.add_title( + PriceTitle( + code=wage_code, + kind=PriceKind.LABOR, + name="조종원", + unit="인", + slots=_slots(wages[wage_code]), + ) + ) + book.add_detail( + PriceDetail(hourly_code, wage_code, per_hour_person, note="조종원 (1일 8시간)") + ) + else: + incomplete.append(f"{code} (조종원 없음)") + + return incomplete + + +@lru_cache(maxsize=1) +def load_basis_missing( + file_name: str = "basis_missing_2026-01-01.json", +) -> dict[str, str]: + """B08 이 낸 **밑수 못 찾은 표** 목록 — `{표 번호: 절 이름}`. + + 「10㎡당」 같은 기준을 원문에서 못 찾은 표다. 1 단위당으로 단정하면 곱셈이 + 10배·100배 틀리므로(떼채취가 실제로 100배였다) 그 표를 쓰는 공종은 + **금액을 안 만든다**. + """ + import json + import os + + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + path = os.path.join(root, "resources", "data_work_item_master", file_name) + if not os.path.exists(path): + return {} + with open(path, encoding="utf-8") as handle: + payload = json.load(handle) + return { + str(item.get("pum_table_id")): str(item.get("section", "")) + for item in payload.get("items", []) + } + + +#: 품셈 원문이 섞어 쓰는 물결표 — 「직경40㎝이상∼60㎝미만」(U+223C)과 +#: 「직경40㎝이상~60㎝미만」(U+FF5E)이 **같은 뜻인데 키가 두 벌**이었다(2026-09-08 실측: +#: 13-6-1·2 는 ∼, 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다. +#: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다 +#: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다). +#: 물결표 목록은 `B09_Estimation_ResourceAxis.RANGE_DASHES` 한 곳에서 온다 — +#: 붙임표(`-`·`–`)는 갈래 이름에 안 쓰이므로 물결표만 골라 쓴다. +_TILDE_CHARS = "".join(ch for ch in RANGE_DASHES if ch not in "-–‐") + + +def normalize_variant_key(text: str) -> str: + """갈래 키 정규화 — 공백을 지우고 물결표를 한 종류(`~`)로 모은다.""" + tight = "".join(str(text).split()) + return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight) + + +def find_variant_code( + work_item_code: str, + variant_value: str, + build: UnitPriceBuild | None = None, +) -> str | None: + """B08 이 보낸 **저장 제원 원본값**(「60~80」)을 내 갈래 코드로 옮긴다. + + 갈래 키는 품셈 원문에서 나오고 **그 원문을 읽는 쪽이 여기**다(2026-09-08 두 창 합의). + 못 맞추면 `None` — **가까운 갈래를 임의로 고르지 않는다.** + """ + prices = build or cached_build() + wanted = normalize_variant_key(variant_value) + if not wanted: + return None + + prefix = f"B-{work_item_code}#" + candidates = [code for code in prices.book.titles if code.startswith(prefix)] + for code in candidates: + if normalize_variant_key(code[len(prefix) :]) == wanted: + return code + # ⚠ 글자 포함으로는 안 맞는다 — 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없다** + # (사이에 「㎝이상」이 낀다). **수의 짝**으로 견준다: [60, 80] == [60, 80]. + numbers = _numbers_of(wanted) + if not numbers: + return None + hits = [ + code + for code in candidates + if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers + ] + if len(hits) == 1: + return hits[0] + if len(numbers) == 1: + # 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는 + # 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다. + return _bracket_for(numbers[0], candidates, prefix) + return None + + +def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None: + """그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다.""" + best: tuple[Decimal, str] | None = None + for code in candidates: + label = normalize_variant_key(code[len(prefix) :]) + bounds = _numbers_of(label) + if len(bounds) == 1: + if "이하" in label and value <= bounds[0]: + if best is None or bounds[0] < best[0]: + best = (bounds[0], code) + elif len(bounds) == 2 and bounds[0] <= value <= bounds[1]: + width = bounds[1] - bounds[0] + if best is None or width < best[0]: + best = (width, code) + return best[1] if best else None + + +def _numbers_of(text: str) -> list[Decimal]: + """그 문자열에 나오는 수들 — 「직경60㎝이상~80㎝미만」 → [60, 80].""" + return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)] + + +def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: + """자원 축을 일위대가(`B`)로 조립한다. + + 공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은 + 공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다. + """ + master = load_work_item_master() + if axis is None: + axis = build_resource_axis(master, load_combined_catalog()) + # 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다. + names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])} + + build = UnitPriceBuild() + build.component_gaps = dict(axis.partial_items) + missing_basis = load_basis_missing() + wages = load_operator_wages() + _add_labor_titles(build.book, wages) + + machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"} + # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. + machine_codes |= formula_machine_codes(master) + build.incomplete_machines = _add_machine_layers(build.book, machine_codes) + + # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 + # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. + by_item: dict[tuple[str, str], list] = {} + for row in axis.rows: + by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row) + + # ⚠ **자원 줄이 하나도 없어도 공식이 온전하면 세운다.** 기계만 쓰는 공종(층따기 9-18 · + # 쇄석 부설 11-4)은 표에 인력 줄이 없어 자원 축이 비고, 그러면 아래 반복문이 그 공종을 + # 아예 안 본다 — 「미판정」으로 남아 있던 것이 실은 **시공능력 공식표**였다 + # (2026-09-08 미판정 77건을 훑다 발견). + for node in master.get("work_items", []): + code = node.get("work_item_code") + if not code or (code, "") in by_item: + continue + # 불도저 운반(8-2-1)은 **갈래(토사·파쇄암·발파암)마다 값이 다르다** — 한 벌로 + # 뭉치면 어느 것도 안 맞는다. 갈래가 있으면 갈래를 세우고 여기서 끝낸다. + labels = dozer_variants(node) + if labels: + for label in labels: + by_item.setdefault((code, label), []) + continue + for table in node.get("tables", []): + if isinstance(extract_cycle_factors(code, table), CycleFactors): + by_item[(code, "")] = [] + break + + for (work_item_code, variant), rows in sorted(by_item.items()): + # 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로** + # (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해 + # 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다. + variant_key = normalize_variant_key(variant) + title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "") + if title_code in build.book.titles: + continue + # ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.** + # 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다 + # (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음). + attachable = [ + (row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}") + for row in rows + ] + attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles] + if not attachable and not _has_full_formula(master, work_item_code): + # 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지). + build.skipped.append(work_item_code) + continue + + # ⚠ 밑수를 못 찾은 표를 쓰면 **곱하면 안 되는 줄**로 표시한다. + for row in rows: + section = missing_basis.get(str(row.pum_table_id)) + if section: + build.basis_missing[work_item_code] = section + break + + unit = next((r.amount_unit for r in rows if r.amount_unit), "") + if not unit: + # ⚠ 단위가 없으면 **내역서의 단위 불일치 검사가 못 걸린다** — 층따기가 + # 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었다. 품셈 원문에 적힌 것만 + # 채우고(「(단위: ㎥당)」·「Q= ㎥/시간」), 없으면 비워 둔다. + unit = work_item_unit(work_item_code) or "" + base_name = names.get(work_item_code) or work_item_code + build.book.add_title( + PriceTitle( + code=title_code, + kind=PriceKind.UNIT_PRICE, + name=f"{base_name} ({variant})" if variant else base_name, + spec=variant or work_item_code, + unit=unit, + ) + ) + if variant_key: + build.variants.setdefault(work_item_code, []).append(variant) + # 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다. + for row, ref in attachable: + share = _share_of(row) + build.book.add_detail(PriceDetail(title_code, ref, row.amount * share)) + + # 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③). + # ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면 + # 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다. + # ⚠ **「상한」이다** — 곱한 값 이하로 계상하는 값이라 산출근거에 그 사실을 적는다. + ratio = axis.overhead_ratio.get(work_item_code) + if ratio is not None and not variant_key.startswith("__"): + lower = ratio[1] + build.book.add_detail( + PriceDetail( + title_code, + title_code, + _ZERO, + note=f"제잡비 노무비의 {lower}% (상한, 물빼기 파이프 미설치 기준)", + percent_of_labor=lower, + ) + ) + + # 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4). + # ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번 + # 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다. + machine_share = attach_dozer_share( + build.book, build.factor_gaps, master, work_item_code, title_code, variant + ) + if not machine_share and not variant: + machine_share = attach_machine_share( + build.book, + build.factor_gaps, + build.cycle_factors, + master, + work_item_code, + title_code, + ) + + # ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.** + # 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이 + # 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥ + # 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다. + # 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다. + if work_item_code in axis.partial_items: + build.partial_ratio.setdefault(work_item_code, _ZERO) + + # ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어 + # 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다. + if not attachable and not build.book.details.get(title_code): + build.book.titles.pop(title_code, None) + if variant in build.variants.get(work_item_code, []): + build.variants[work_item_code].remove(variant) + build.skipped.append(work_item_code) + continue + + covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build) + if covered is not None: + covered += machine_share + if covered < Decimal(100): + build.partial_ratio[work_item_code] = covered + return build + + +def _has_full_formula(master: dict, work_item_code: str) -> bool: + """그 공종에 **온전한 시공능력 공식**이 있는가 (기계만 쓰는 공종용).""" + node = next( + (w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code), + None, + ) + if node is None: + return False + return any( + isinstance(extract_cycle_factors(work_item_code, table), CycleFactors) + or isinstance(extract_dozer_factors(work_item_code, table), dict) + for table in node.get("tables", []) + ) + + +def _share_of(row) -> Decimal: + """그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다.""" + ratio = getattr(row, "group_ratio_pct", None) + return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100) + + +def _covered_ratio_pct( + rows: list, attached_refs: set[str], build: UnitPriceBuild +) -> Decimal | None: + """배분율 표에서 **실제로 붙은 몫**의 합계(%). 배분율이 없는 표면 `None`.""" + ratios = { + row.group_ratio_pct for row in rows if getattr(row, "group_ratio_pct", None) is not None + } + if not ratios: + return None + covered = Decimal(0) + seen: set[Decimal] = set() + for row in rows: + ratio = getattr(row, "group_ratio_pct", None) + if ratio is None or ratio in seen: + continue + ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}" + if ref in attached_refs: + seen.add(ratio) + covered += ratio + return covered + + +def material_total_before_surcharge(build: UnitPriceBuild, code: str) -> Decimal: + """일위대가 한 줄의 **할증 전** 재료비 합계 — ㉠ 가드에 넘길 값.""" + return build.book.resolve(code).material + + +def verify_surcharge_once( + build: UnitPriceBuild, + code: str, + *, + material_summary_total: Decimal, + surcharge_rate_percent: Decimal, +) -> None: + """㉠ 자재총괄 합과 대조한다 — 할증이 두 번 붙었으면 여기서 멈춘다.""" + check_surcharge_once( + material_summary_total=material_summary_total, + unit_price_material_total=material_total_before_surcharge(build, code), + surcharge_rate_percent=surcharge_rate_percent, + label=code, + ) + + +#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축). +SOURCE_INDEX: dict[PriceKind, int] = { + PriceKind.MATERIAL: 5, + PriceKind.LABOR: 6, + PriceKind.MACHINE_BASE: 105, + PriceKind.MACHINE_HOURLY: 105, + PriceKind.UNIT_PRICE: 103, + PriceKind.PRICE_BASIS: 104, + PriceKind.LUMPSUM: 0, +} +SOURCE_LABEL: dict[PriceKind, str] = { + PriceKind.MATERIAL: "자재", + PriceKind.LABOR: "노임", + PriceKind.MACHINE_BASE: "기계경비", + PriceKind.MACHINE_HOURLY: "기계경비", + PriceKind.UNIT_PRICE: "일위대가", + PriceKind.PRICE_BASIS: "단가산출", + PriceKind.LUMPSUM: "일식·견적", +} + +#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다. +DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS}) + + +@lru_cache(maxsize=1) +def cached_build() -> UnitPriceBuild: + """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.""" + return build_unit_prices() + + +@dataclass +class DirectCostBreakdown: + """⑤ 공사원가계산서가 받는 **직접비 3분할**. + + ⚠ **일위대가 합계를 순공사비로 뭉쳐 넣으면 안 된다.** ⑤ 의 밑수는 항목마다 갈리고 + (산재·고용 = 노무비 / 건강·연금 = 직접노무비 / 기타경비 = 재료비+노무비 …), + 뭉쳐 넣으면 그 밑수가 전부 틀린다(PLAN 8-9 규칙 2). 일위대가는 3분할을 이미 + 들고 있으니 **성분별로 접어 넣는다.** + """ + + material: Decimal = _ZERO + labor: Decimal = _ZERO + expense: Decimal = _ZERO + #: 값을 못 세운 공종 — 수량이 있는데 단가가 없으면 여기 남는다(0 으로 안 때운다). + missing: list[str] = field(default_factory=list) + + @property + def total(self) -> Decimal: + return self.material + self.labor + self.expense + + +def direct_cost_from_quantities( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, +) -> DirectCostBreakdown: + """공종별 수량을 일위대가에 곱해 **직접비 3분할**을 만든다. + + `quantities` = `{공종코드: 수량}`. 공종코드는 `FP-09-21` 처럼 마스터 코드를 쓰거나 + `B-FP-09-21` 처럼 일위대가 코드를 그대로 써도 된다. + + 단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남긴다 — 수량이 있는데 단가가 + 없으면 그 공종이 총액에서 조용히 빠진다. + """ + book = (build or cached_build()).book + result = DirectCostBreakdown() + + for raw_code, quantity in quantities.items(): + code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" + if code not in book.titles: + result.missing.append(raw_code) + continue + unit_money = book.resolve(code) + line = unit_money.scaled(Decimal(str(quantity))) + result.material += line.material + result.labor += line.labor + result.expense += line.expense + return result + + +def cost_input_from_quantities( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, + **cost_input_kwargs, +): + """직접비 3분할을 ⑤ 엔진 입력으로 접어 넣는다. + + 성분이 그대로 `direct_material_krw`·`direct_labor_krw`·`direct_expense_krw` 로 간다 — + **뭉치지 않는다.** + """ + from B09_Estimation.B09_Estimation_Engine_Cost import CostInput + + breakdown = direct_cost_from_quantities(quantities, build) + # ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다 + # (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다. + summary = OutputPlace.RESOURCE_SUMMARY + return ( + CostInput( + direct_material_krw=round_at(breakdown.material, summary), + direct_labor_krw=round_at(breakdown.labor, summary), + direct_expense_krw=round_at(breakdown.expense, summary), + **cost_input_kwargs, + ), + breakdown, + ) + + +# 화면용 조회(요약·목록·본표)는 700줄 제한으로 `_View` 파일로 옮겼다. +# **부르는 쪽이 어디서 오는지 신경 쓰지 않게** 여기서 다시 내보낸다. +from B09_Estimation.B09_Estimation_UnitPrice_View import ( # noqa: E402 + build_summary, + detail_of, + list_unit_prices, +) + +__all__ = [ + "UnitPriceBuild", + "build_unit_prices", + "cached_build", + "build_summary", + "detail_of", + "list_unit_prices", + "direct_cost_from_quantities", + "cost_input_from_quantities", + "find_variant_code", + "normalize_variant_key", +] diff --git a/B09_Estimation/B09_Estimation_UnitPrice_View.py b/B09_Estimation/B09_Estimation_UnitPrice_View.py new file mode 100644 index 00000000..14c57ed6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UnitPrice_View.py @@ -0,0 +1,258 @@ +"""B09 원가계산 — 일위대가 **화면용 조회** (요약·목록·본표). + +조립(`B09_Estimation_UnitPrice`)과 **보여주기**를 갈라 둔 파일이다. 700줄 제한(CLAUDE.md +4장)에 걸려 나눴고, 가르는 금은 「값을 만드는가 / 만든 값을 화면 모양으로 옮기는가」다. + +⚠ 단수 처리는 **여기서** 한다 — 계산 함수 안에서 자르지 않는다 +(`B09_Estimation_Rounding` 머리말). 일위대가 금액란은 0.1원 버림이다. +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +from B09_Estimation.B09_Estimation_MachineOperating import load_fuel_price +from B09_Estimation.B09_Estimation_MaterialCatalog import catalog_summary, load_material_catalog +from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import ( + DRILLABLE_KINDS, + SOURCE_INDEX, + SOURCE_LABEL, + SUSPICIOUSLY_HIGH_KRW, + SUSPICIOUSLY_LOW_KRW, + UnitPriceBuild, +) +from B09_Estimation.B09_Estimation_Guards import check_column_sums + +_ZERO = Decimal(0) +_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") + + +def _plain(text: str) -> str: + """화면용 평문 — 마크다운 강조 표시를 벗긴다.""" + return _RE_EMPHASIS.sub(lambda match: match.group(1), text) + + +def _status_notes() -> list[str]: + """화면에 낼 「지금 무엇이 안 선 상태인가」. + + 자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가 + 거의 없다는 것이 이 자리의 진짜 공백이다. + """ + from B09_Estimation.B09_Estimation_MaterialCatalog import ( + catalog_summary, + load_material_catalog, + ) + + summary = catalog_summary(load_material_catalog()) + # 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다. + return [ + _plain(note) + for note in [ + f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — " + "나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 " + "철근·레미콘·아스콘은 원천에서 빠져 있습니다.", + "**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — " + "유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 " + "일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)", + ( + f"관급 자재 **설치 주체가 미지정**" + f"({summary['owner_supplied_install_unspecified']:,}건)이라 " + "안전관리비 대상액에 자동으로 넣지 않습니다." + ), + ] + ] + + +def build_summary(build: UnitPriceBuild) -> dict: + """산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다.""" + kinds: dict[str, int] = {} + for title in build.book.titles.values(): + kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1 + # ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다. + # 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다 + # (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07). + totals = sorted( + build.book.resolve(code).total + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + ) + stats: dict[str, str] = {} + low: list[dict[str, str]] = [] + if totals: + stats = { + "min": _money_text(totals[0]), + "median": _money_text(totals[len(totals) // 2]), + "max": _money_text(totals[-1]), + } + # ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라 + # 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다. + low = [ + {"code": code, "name": title.name, "total": _money_text(money)} + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + and code[2:].split("#")[0] not in build.partial_ratio + and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW + ] + + # 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다. + high = [ + {"code": code, "name": title.name, "total": _money_text(money)} + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE + and not title.unit + and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW + ] + + return { + "titles": len(build.book.titles), + "unit_price_totals": stats, + # 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다. + "suspiciously_low": low, + # 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다. + "blocked_items": len(build.partial_ratio), + # 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다. + "unknown_basis_high": high, + "unknown_basis": sum( + 1 + for code, title in build.book.titles.items() + if title.kind is PriceKind.UNIT_PRICE and not title.unit + ), + "unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0), + "machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0), + "skipped_work_items": len(build.skipped), + "incomplete_machines": len(build.incomplete_machines), + "kinds": kinds, + # ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결). + "notes": _status_notes(), + } + + +def _money_text(value: Decimal) -> str: + """화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2). + + 계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다 + (`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다). + """ + return str(round_at(value, OutputPlace.UNIT_PRICE_ROW)) + + +def list_unit_prices(build: UnitPriceBuild) -> list[dict]: + """목록표 — 「무엇이 있나」 한 줄씩.""" + rows: list[dict] = [] + for code, title in sorted(build.book.titles.items()): + if title.kind is not PriceKind.UNIT_PRICE: + continue + money = build.book.resolve(code) + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "material": _money_text(money.material), + "labor": _money_text(money.labor), + "expense": _money_text(money.expense), + "total": _money_text(money.total), + } + ) + return rows + + +def detail_of(build: UnitPriceBuild, code: str) -> dict: + """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" + title = build.book.title(code) + money = build.book.resolve(code) + rows: list[dict] = [] + for detail in build.book.details.get(code, []): + if detail.percent_of_labor is not None: + # 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야 + # 화면 합계와 실제 단가가 어긋나지 않는다. + # 밑수는 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다 + # (근거 인용 셋은 `PriceBook.resolve` 의 같은 자리 주석). + labor_so_far = sum( + ( + Decimal(str(row_item["labor"])) + for row_item in rows + if row_item.get("kind") == PriceKind.LABOR.value + ), + _ZERO, + ) + amount = labor_so_far * detail.percent_of_labor / Decimal(100) + rows.append( + { + "code": detail.ref_code, + "name": "제잡비", + "spec": f"노무비의 {detail.percent_of_labor}%", + "unit": "%", + "quantity": str(detail.percent_of_labor), + "material": "0", + "labor": "0", + "expense": str(amount), + "total": _money_text(amount), + "source": "품셈 [주]", + "drillable": False, + "note": detail.note, + } + ) + continue + + child = build.book.title(detail.ref_code) + unit_money = build.book.resolve(detail.ref_code) + line = unit_money.scaled(detail.quantity) + rows.append( + { + "ref_code": detail.ref_code, + "name": child.name, + "spec": child.spec, + "unit": child.unit, + # 제잡비 밑수를 가릴 때 쓴다 — 사람 품(`labor`)만 센다. + "kind": child.kind.value, + "source_index": SOURCE_INDEX.get(child.kind, 0), + "source_label": SOURCE_LABEL.get(child.kind, ""), + "drillable": child.kind in DRILLABLE_KINDS, + "quantity": str(detail.quantity), + "unit_material": _money_text(unit_money.material), + "unit_labor": _money_text(unit_money.labor), + "unit_expense": _money_text(unit_money.expense), + "unit_total": _money_text(unit_money.total), + "material": _money_text(line.material), + "labor": _money_text(line.labor), + "expense": _money_text(line.expense), + # 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다. + # 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다. + "total": str( + round_at(line.material, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.labor, OutputPlace.UNIT_PRICE_ROW) + + round_at(line.expense, OutputPlace.UNIT_PRICE_ROW) + ), + "note": detail.note, + } + ) + # 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」 + # (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다. + summed = { + key: sum((Decimal(r[key]) for r in rows), Decimal(0)) + for key in ("material", "labor", "expense", "total") + } + # ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다. + # 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다. + check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표") + return { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "kind": title.kind.value, + "material": str(summed["material"]), + "labor": str(summed["labor"]), + "expense": str(summed["expense"]), + "total": str(summed["total"]), + # TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다. + "sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"], + # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. + "precise_total": _money_text(money.total), + "rows": rows, + } diff --git a/B09_Estimation/B09_Estimation_WorkItemUnit.py b/B09_Estimation/B09_Estimation_WorkItemUnit.py new file mode 100644 index 00000000..ec05eb27 --- /dev/null +++ b/B09_Estimation/B09_Estimation_WorkItemUnit.py @@ -0,0 +1,168 @@ +"""B09 — **공종 단가의 기준 단위**를 품셈 원문에서 읽는다 (2026-09-08). + +**왜 있는가** — 일위대가 211개 중 **73개가 단위를 못 달고** 있었다. 단위가 없으면 +내역서의 **단위 불일치 검사가 못 걸린다** — 실제로 층따기가 「㎡ 수량 × ㎥당 단가」로 +**4,102,708원**을 내고 있었다(돌쌓기가 「m × ㎡당」이던 것과 같은 병). + +단위가 적힌 자리는 원문에 **둘**이다. + + ① 절 머리 아래 한 줄 — 「### 13-4-5. 찰쌓기(장비)」 다음의 「(단위: ㎡당)」 + ② 공식형 표의 [주] — 「Q1=3600×q×K×f×E/㎝= **㎥/시간**」 (층따기 9-18) + +⚠ **「인 당」·「일 당」은 공종 단위가 아니다.** 그것은 **품의 단위**(사람·날)라, 공종 +단위로 쓰면 「몇 인짜리 공종」이라는 뜻이 된다. 그래서 **물리 수량 단위만** 받는다. + +⚠ **없으면 비워 둔다.** 마스터 `basis_unit` 도 대개 비어 있고(73개 중 대부분 `None`), +못 찾은 것을 짐작으로 채우면 **틀린 단위로 검사를 통과**시켜 오히려 더 나쁘다. +「미확보」로 두면 화면이 「기준 단위가 표에 없습니다」로 드러낸다. +""" + +from __future__ import annotations + +import os +import re +from functools import lru_cache + +#: 산림사업 표준품셈 원문 (FP-* 공종의 출처). +_FOREST_SPEC = ( + "resources", + "knowledge", + "original", + "행정규칙", + "임도 품셈 적용기준 (현 산림사업 표준품셈)", + "첨부", + "(산림청고시 제2025-82호) 산림사업 표준품셈.md", +) + +#: **공종 수량 단위로 인정하는 것.** 여기 없는 표기(인·일·회·시간)는 품의 단위이지 +#: 공종의 단위가 아니다 — 받으면 「몇 인짜리 공종」이 된다. +_QUANTITY_UNITS = frozenset( + { + "㎡", + "㎥", + "m", + "㎞", + "개", + "개소", + "본", + "주", + "ton", + "톤", + "㏊", + "ha", + "kg", + "㎏", + "매", + "장", + "식", + } +) + +_RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$") +#: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다. +_RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[::]\s*(.+?)\s*\)\s*$") +#: 「단위:」를 빼고 **괄호만** 적는 절이 있다 — 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」. +#: 씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 안 읽히고 있었다(2026-09-08). +#: +#: ⚠ **앞의 「인/」은 품의 단위이고 뒤가 공종 단위다** — 「인/㎡당」은 「㎡당 몇 인」이라 +#: 공종 단위가 ㎡ 다. 그래서 그것만 건너뛴다. +#: ⚠ **「㎥/1대, 1일」·「대/ton」 같은 것은 안 받는다** — 그건 소요량이 아니라 +#: **시공량**(1대가 하루에 몇 ㎥)이라, 공종 단위로 읽으면 뜻이 뒤집힌다. +#: 쉼표가 들어간 것(「1ha당, 100본당」)도 밑수가 둘이라 안 받는다. +_RE_BARE_UNIT_LINE = re.compile(r"^\(\s*(?:인\s*/\s*)?(?:[\d.,]+\s*)?([^\s/,()]+?)\s*당?\s*\)\s*$") +#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」. +#: ⚠ 등호와 단위 사이에 **수가 끼는 표기**가 있다 — 「A = 77.7 ㎡/시간」(9-17-1 비탈면 다짐). +#: 그 수를 건너뛰지 않으면 그 절이 통째로 안 읽힌다. +_RE_FORMULA_UNIT = re.compile(r"=\s*(?:[\d.,]+\s*)?([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b") + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _clean_unit(text: str) -> str | None: + """「100㎡당」 → 「㎡」. 공종 단위가 아니면 `None`.""" + tight = "".join(str(text).split()) + tight = re.sub(r"^\d+(?:,\d{3})*(?:\.\d+)?", "", tight) # 밑수 수량은 뗀다 + tight = tight[:-1] if tight.endswith("당") else tight + return tight if tight in _QUANTITY_UNITS else None + + +@lru_cache(maxsize=1) +def section_units() -> dict[str, str]: + """절 번호 → 공종 단위. **원문에 적힌 것만** 담는다.""" + path = os.path.join(_project_root(), *_FOREST_SPEC) + try: + lines = open(path, encoding="utf-8").read().splitlines() + except OSError: + return {} + + units: dict[str, str] = {} + section: str | None = None + for raw in lines: + line = raw.strip() + heading = _RE_HEADING.match(line) + if heading: + section = heading.group(1) + # ⚠ 절 제목 자체가 식을 품는 경우가 있다 — 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」. + found = _RE_FORMULA_UNIT.search(heading.group(2)) + if found and section not in units: + units[section] = found.group(1) + continue + if section is None or section in units: + continue + unit_line = _RE_UNIT_LINE.match(line) or _RE_BARE_UNIT_LINE.match(line) + if unit_line: + cleaned = _clean_unit(unit_line.group(1)) + if cleaned: + units[section] = cleaned + continue + found = _RE_FORMULA_UNIT.search(line) + if found: + units[section] = found.group(1) + return units + + +def section_of(work_item_code: str) -> str: + """공종 코드 → 절 번호. `FP-13-04-05` → `13-4-5`.""" + parts = str(work_item_code or "").split("-") + if not parts or parts[0] != "FP": + return "" + numbers = [] + for part in parts[1:]: + if not part.isdigit(): + return "" + numbers.append(str(int(part))) + return "-".join(numbers) + + +def unit_of(work_item_code: str) -> str | None: + """그 공종 단가의 기준 단위. **원문에 없으면 `None`** — 짐작으로 채우지 않는다.""" + section = section_of(work_item_code) + if not section: + return None + units = section_units() + # 하위 절에 안 적혀 있으면 **한 단계 위 절**을 본다 — 「13-4-5」가 없으면 「13-4」. + # ⚠ 두 단계 위까지는 안 올라간다. 장(13) 전체는 공종이 섞여 단위가 하나가 아니다. + if section in units: + return units[section] + + # ⚠ **상위 절은 스스로 단위를 안 적고 하위 절만 적는 경우가 많다** — 「9-5. 발파암」은 + # 빈 제목이고 9-5-1·9-5-2·9-5-3 이 각각 ㎥ 를 밝힌다(암절취 9-4·노체 9-16·덤프운반 + # 10-12 도 같다). **하위가 모두 같은 단위일 때만** 그 단위를 상위에 준다 — + # 갈리면 안 준다(한 단위로 뭉뚱그리면 어느 하위가 틀렸는지 못 가린다). + # + # ⚠ **둘 이상이 말할 때만 듣는다.** 하나만 말한 것을 상위에 주면 장(章) 전체가 + # 그 하나의 단위가 된다 — 실제로 「13. 돌공사」가 하위 한 절 때문에 **ton** 으로 + # 나왔다(2026-09-08). 그래서 ㉠ 절 번호에 하이픈이 있고(장 자체가 아니고) + # ㉡ **말한 하위가 둘 이상**이며 ㉢ 그 값이 하나로 모일 때만 준다. + declared = [ + value + for key, value in units.items() + if key.startswith(f"{section}-") and key.count("-") == section.count("-") + 1 + ] + if "-" in section and len(declared) >= 2 and len(set(declared)) == 1: + return declared[0] + + parent = section.rsplit("-", 1)[0] + return units.get(parent) if parent != section and "-" in section else None 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_chart_ywindow.ts b/common_util/common_util_chart_ywindow.ts new file mode 100644 index 00000000..f0ad8d96 --- /dev/null +++ b/common_util/common_util_chart_ywindow.ts @@ -0,0 +1,205 @@ +/* ============================================================================= + * common_util_chart_ywindow.ts + * 종단 그래프의 **세로 창을 변환으로 갈아 끼운다** — 다시 그리지 않는다(2026-09-04 확정). + * + * 예전에는 스크롤을 멈출 때마다 그래프를 통째로 새로 만들었다(SVG 요소 457개, 실측 + * 34ms = 60Hz 두 프레임 누락). 사용자에게는 「화면을 리프레시하는 느낌」으로 왔다. + * + * 세로 매핑은 1차식이라(`y = zero − (표고 − center) × pxPerM`) 옛 창과 새 창의 관계도 + * 1차식이다. 그래서 도형을 건드리지 않고 겹 하나에 `translate`+`scale` 만 걸면 정확히 + * 겹친다. 글자는 늘어나므로 변환에 넣지 않고 자리만 옮긴다(눈금 10개 안팎). + * + * 창이 많이 벌어져 눈금 간격이 원래 의도(1·2·5 계열)와 어긋나면 **눈금만** 다시 만든다. + * 그때도 눈금은 **그릴 때의 좌표계**에 놓아 같은 변환이 그대로 먹게 한다. + * ========================================================================== */ + +import { + Y_GRID_CLASS, + Y_TICK_CLASS, + Y_WINDOW_ATTRS, + Y_WINDOW_BAKED_ATTR, + Y_WINDOW_CLASS, + Y_WINDOW_SPAN_PAD, + yWindowTickValues, +} from "../B06_Section/B06_Section_UI_Longitudinal"; + +/** 눈금을 다시 만드는 문턱 — 그릴 때 대비 세로 배율이 이 밖으로 나가면 간격이 어긋난다. */ +const TICK_KEEP_MIN = 0.8; +const TICK_KEEP_MAX = 1.25; +/** 플롯 안에 남아야 하는 최소 눈금 수. */ +const TICK_KEEP_MIN_COUNT = 4; +/** 세로 창을 따라 움직이는 좌측 고정 축에 붙이는 표식 — 한 컨테이너에 축이 둘 이상일 때 + * (B06: 종단 + 유토곡선) 남의 축까지 옮기지 않으려는 것이다. */ +export const Y_AXIS_WINDOW_CLASS = "b05-profile__yaxis--ywindow"; +/** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */ +const TICK_TEXT_DROP_PX = 4; + +interface Baked { + svg: SVGSVGElement; + center: number; + pxPerM: number; + zero: number; + top: number; + height: number; + /** 세로 과장 — 새 창의 px 환산에 그대로 곱한다(B06 조절값, B05 는 1). */ + exaggeration: number; +} + +export interface ElevationWindowResult { + /** 그릴 때 대비 세로 배율(1이면 그릴 때와 같은 창). */ + scale: number; + /** 플롯 안에 남아 있는 눈금 수. */ + visibleTicks: number; + /** 이번 호출에서 눈금을 다시 만들었는가. */ + rebuiltTicks: boolean; +} + +function readBaked(root: ParentNode): Baked | null { + const svg = root.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); + if (!svg) return null; + const read = (name: string): number => Number(svg.getAttribute(name)); + const baked: Baked = { + svg, + center: read(Y_WINDOW_ATTRS.center), + pxPerM: read(Y_WINDOW_ATTRS.pxPerM), + zero: read(Y_WINDOW_ATTRS.zero), + top: read(Y_WINDOW_ATTRS.top), + height: read(Y_WINDOW_ATTRS.height), + exaggeration: read(Y_WINDOW_ATTRS.exaggeration) || 1, + }; + const numbers = [baked.center, baked.pxPerM, baked.zero, baked.top, baked.height]; + if (!numbers.every(Number.isFinite) || baked.pxPerM <= 0 || baked.height <= 0) return null; + return baked; +} + +/** 눈금선·눈금 글자·좌측 고정 축을 새 창의 눈금값으로 다시 만든다(그래프는 건드리지 않는다). + * + * 자리는 **그릴 때의 좌표계**로 잡는다 — 그래야 아래 변환이 눈금에도 그대로 먹는다. */ +function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: number): boolean { + const grid = baked.svg.querySelector(`.${Y_GRID_CLASS}`); + const labels = baked.svg.querySelector(`.${Y_TICK_CLASS}`); + if (!grid || !labels) return false; + const sampleLine = grid.querySelector("line"); + const sampleText = labels.querySelector("text"); + if (!sampleLine || !sampleText) return false; + const x1 = sampleLine.getAttribute("x1") ?? "0"; + const x2 = sampleLine.getAttribute("x2") ?? "0"; + const lineClass = sampleLine.getAttribute("class") ?? ""; + const textX = sampleText.getAttribute("x") ?? "0"; + const textClass = sampleText.getAttribute("class") ?? ""; + const axis = root.querySelector(`.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-inner`); + + const ns = "http://www.w3.org/2000/svg"; + const lines: SVGLineElement[] = []; + const texts: SVGTextElement[] = []; + const spans: HTMLElement[] = []; + // 눈금값은 **과장을 뺀** 표고 폭 기준이다(그리는 쪽 `rawSpan` 과 같은 값). + for (const { value, label } of yWindowTickValues( + center, + span / baked.exaggeration, + baked.height, + )) { + const y = baked.zero - (value - baked.center) * baked.pxPerM; + const line = document.createElementNS(ns, "line"); + line.setAttribute("x1", x1); + line.setAttribute("x2", x2); + line.setAttribute("y1", String(y)); + line.setAttribute("y2", String(y)); + line.setAttribute("class", lineClass); + lines.push(line); + const text = document.createElementNS(ns, "text"); + text.setAttribute("x", textX); + text.setAttribute("y", String(y + TICK_TEXT_DROP_PX)); + text.setAttribute("text-anchor", "end"); + text.setAttribute("class", textClass); + text.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); + text.textContent = label; + texts.push(text); + if (axis) { + const span = document.createElement("span"); + span.className = "b05-profile__yaxis-tick"; + span.style.top = `${y}px`; + span.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); + span.textContent = label; + spans.push(span); + } + } + grid.replaceChildren(...lines); + labels.replaceChildren(...texts); + if (axis) axis.replaceChildren(...spans); + return true; +} + +/** 지금 그려져 있는 눈금 글자 중 새 창 안에 남는 개수. */ +function countTicksInside(baked: Baked, scale: number, offset: number): number { + let count = 0; + baked.svg.querySelectorAll(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => { + const moved = scale * Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)) + offset; + if (moved >= baked.top && moved <= baked.top + baked.height) count += 1; + }); + return count; +} + +/** + * 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null. + * 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다. + */ +export function applyElevationWindow( + root: ParentNode, + range: { min: number; max: number } | undefined, +): ElevationWindowResult | null { + const baked = readBaked(root); + if (!baked || !range) return null; + + // 새 창 — 그리는 쪽과 **같은 규칙**으로 만든다(같은 상수를 같이 쓴다). + const span = Math.max(range.max - range.min, 1) * Y_WINDOW_SPAN_PAD; + const center = (range.min + range.max) / 2; + const pxPerM = (baked.exaggeration * baked.height) / span; + const scale = pxPerM / baked.pxPerM; + + const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM; + + // 눈금을 새로 만들 때 — 둘 중 하나만 걸려도 만든다(요소 20개 안팎이라 싸다). + // ① 배율이 크게 벌어짐 — 눈금 간격이 원래 의도(1·2·5 계열)와 어긋난다. + // ② **새 창에 있어야 할 눈금 수와 지금 화면에 남은 수가 다름** — 창이 위아래로 이동만 + // 하면 배율은 그대로인데 눈금이 한쪽으로 쓸려 나가 그 자리가 빈다(2026-09-04 사용자 + // 보고: 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우」). + const wanted = yWindowTickValues(center, span / baked.exaggeration, baked.height).length; + const staying = countTicksInside(baked, scale, offset); + let rebuiltTicks = false; + if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX || staying !== wanted) { + rebuiltTicks = rebuildTicks(baked, root, center, span); + } + + const transform = `translate(0 ${offset}) scale(1 ${scale})`; + baked.svg.querySelectorAll(`.${Y_WINDOW_CLASS}`).forEach((layer) => { + layer.setAttribute("transform", transform); + }); + + const place = (bakedY: number): number => scale * bakedY + offset; + const inside = (y: number): boolean => y >= baked.top && y <= baked.top + baked.height; + let visibleTicks = 0; + baked.svg.querySelectorAll(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => { + const moved = place(Number(label.getAttribute(Y_WINDOW_BAKED_ATTR))); + // 글자를 내리는 몫은 **변환 뒤에** 더한다 — 배율과 함께 커지면 선에서 떨어져 보인다. + label.setAttribute("y", String(moved + TICK_TEXT_DROP_PX)); + // 플롯 밖으로 나간 눈금 글자는 감춘다 — 축 라벨·측점 띠 위로 흘러나오면 안 된다. + label.style.display = inside(moved) ? "" : "none"; + if (inside(moved)) visibleTicks += 1; + }); + root + .querySelectorAll( + `.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`, + ) + .forEach((tick) => { + const moved = place(Number(tick.getAttribute(Y_WINDOW_BAKED_ATTR))); + tick.style.top = `${moved}px`; + tick.style.display = inside(moved) ? "" : "none"; + }); + return { scale, visibleTicks, rebuiltTicks }; +} + +/** 눈금이 더는 쓸 만하지 않은가 — 패널이 그래프 전체를 다시 만들지 판단하는 기준. */ +export function needsFullRedraw(result: ElevationWindowResult | null): boolean { + return !result || (result.visibleTicks < TICK_KEEP_MIN_COUNT && !result.rebuiltTicks); +} diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py new file mode 100644 index 00000000..4435b560 --- /dev/null +++ b/common_util/common_util_cross_berm.py @@ -0,0 +1,240 @@ +"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). + +짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 +`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. + +**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. +지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 +겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신 +**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 +값이 나온다(거울 시험이 그것도 지킨다). + +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. +· 폭·간격은 별표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 로 서서 다른 비탈이 된다. +· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다). + 기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다. + 그래서 지식DB 에도 적지 않는다(사용자 지시). +""" + +import logging +import math +from typing import Callable, NamedTuple + +logger = logging.getLogger(__name__) + +# 소단 기본값 — 근거는 위 모듈 설명. +BERM_DEFAULT_WIDTH_M = 0.5 +BERM_DEFAULT_INTERVAL_M = 3.0 +BERM_DEFAULT_SLOPE_DEG = 0.0 + +# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. +_STEP_M = 0.05 +_MAX_REACH_M = 200.0 +# 걸음 수 상한 — 정상 경로의 최대는 200/0.05 = 4,000 이다. 소단은 걸음 없이 거리를 더하므로 +# 여유를 크게 두고 **10배**로 잡는다. 넘으면 조용히 자르지 않고 경고를 남긴다 — 조용히 +# 자르면 절토선이 짧아진 채 값이 나가 또 조용히 틀린다(2026-09-07 25 지적). +_MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10 + + +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 + + # ⚠ 제자리 무릎을 막는 자리 — 경계선 기울기가 **암 경사와 토사 경사 사이**면 「토사로 + # 바꾸면 경계 아래, 암으로 바꾸면 경계 위」가 되어 같은 자리에서 영원히 뒤집힌다 + # (보간 비율 `share` 가 0 이라 한 걸음도 안 나간다). 그러면 화면이 통째로 멈춘다 + # (2026-09-07 실사고 — 소단 한 건을 놓자 브라우저가 25분간 안 끝남). 직전 무릎 자리를 + # 들고 있다가 **같은 자리면 뒤집지 않고 한 걸음 나아간다**. + last_knee_dist = float("-inf") + steps = 0 + while dist < limit: + steps += 1 + if steps > _MAX_STEPS: + logger.warning( + "절토 사면 걸음이 상한(%d)을 넘어 멈춥니다 — 거리 %.3fm, 경사비 %.3f. " + "제자리 무릎이 남아 있을 수 있습니다.", + _MAX_STEPS, + dist, + ratio, + ) + break + 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 + # 앞으로 나아가는 무릎만 인정한다(위 ⚠ 참조). 같은 자리면 그냥 한 걸음 간다. + if knee_dist > last_knee_dist + 1e-9: + 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 + last_knee_dist = knee_dist + continue + + dist, elevation = next_dist, next_z + slant_since_berm += slant + + points.append((dist, elevation)) + return _dedupe(points) + + +def fill_profile_points( + start_dist: float, + start_z: float, + fill_ratio: float, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, +) -> list[tuple[float, float]]: + """성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**. + + 절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 — + 사면길이가 `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 + ) + ratio = max(fill_ratio, 1e-6) + + while dist < limit: + drop = _STEP_M / ratio + slant = math.hypot(_STEP_M, drop) + 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 + dist += _STEP_M + elevation -= drop + 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..a4842290 --- /dev/null +++ b/common_util/common_util_cross_berm.ts @@ -0,0 +1,223 @@ +/* ============================================================================= + * 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 · 안쪽 기울기 0°. + * · 폭·간격은 별표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**. + * · 안쪽 기울기 기본값은 **0°**다(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 = 0.0; + +/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ +const STEP_M = 0.05; +const MAX_REACH_M = 200.0; +/** 걸음 수 상한 — 정상 경로의 최대는 200/0.05 = 4,000. 소단은 걸음 없이 거리를 더하므로 + * 여유를 크게 두고 10배로 잡는다. 넘으면 조용히 자르지 않고 콘솔에 알린다. + * 짝: 파이썬 `_MAX_STEPS`. */ +const MAX_STEPS = (MAX_REACH_M / STEP_M) * 10; + +/** 소단 제원 — 폭(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; + + // ⚠ 제자리 무릎을 막는 자리 — 경계선 기울기가 **암 경사와 토사 경사 사이**면 「토사로 + // 바꾸면 경계 아래, 암으로 바꾸면 경계 위」가 되어 같은 자리에서 영원히 뒤집힌다(보간 비율 + // `share` 가 0 이라 한 걸음도 안 나간다). 그러면 화면이 통째로 멈춘다(2026-09-07 실사고 — + // 소단 한 건을 놓자 브라우저가 25분간 안 끝남). 직전 무릎 자리를 들고 있다가 **같은 자리면 + // 뒤집지 않고 한 걸음 나아간다**. 짝: 파이썬 `cut_profile_points`. + let lastKneeDist = Number.NEGATIVE_INFINITY; + let steps = 0; + while (dist < limit) { + steps += 1; + if (steps > MAX_STEPS) { + console.warn( + `절토 사면 걸음이 상한(${MAX_STEPS})을 넘어 멈춥니다 — 거리 ${dist.toFixed(3)}m, 경사비 ${ratio}.`, + ); + break; + } + 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; + // 앞으로 나아가는 무릎만 인정한다(위 ⚠ 참조). 같은 자리면 그냥 한 걸음 간다. + if (kneeDist > lastKneeDist + 1e-9) { + slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); + dist = kneeDist; + elevation = kneeZ; + points.push([dist, elevation]); // 무릎 + inSoil = !inSoil; + ratio = inSoil ? soilCutRatio : cutRatio; + lastKneeDist = kneeDist; + continue; + } + } + } + + dist = nextDist; + elevation = nextZ; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + +/** + * 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**. + * + * 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다. + */ +export function fillProfilePoints( + startDist: number, + startZ: number, + fillRatio: number, + berm: BermSpec | null, + maxReachM: number = MAX_REACH_M, +): 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; + const ratio = Math.max(fillRatio, 1e-6); + + while (dist < limit) { + const drop = STEP_M / ratio; + const slant = Math.hypot(STEP_M, drop); + 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; + } + dist += STEP_M; + elevation -= drop; + 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 new file mode 100644 index 00000000..6ebb2ec1 --- /dev/null +++ b/common_util/common_util_cross_design.ts @@ -0,0 +1,476 @@ +/* ============================================================================= + * common_util_cross_design.ts + * B06 측점 표준횡단 설계 계산 — **브라우저 판**. + * + * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠ + * 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`) + * 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`. + * 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다: + * `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것. + * + * ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ──────────────── + * 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션 + * 캐시에 쌓이고 화면은 즉시 따라오며, 서버는 [저장]·[확정]에서만 부른다. 계획고가 바뀔 + * 때마다 서버에 전 측점 횡단을 물으면 왕복이 조작 속도를 지배한다(2026-09-03 실측 보고). + * 계획선 선형(`B05_Profile_Engine_Grade_Alignment.py` ↔ `B05_Profile_UI_Profile_Alignment.ts`) + * 이 이미 같은 이유로 1:1 미러다. + * + * ── 갈라지지 않게 하는 규칙 ────────────────────────────────────────── + * 1. **config 상수를 여기에 복제하지 않는다.** 표준단면 수치는 서버가 config 에서 내려 + * 주는 `sections/context.standard_cross_section` 을 입력으로 받는다. 아래 상수는 + * config 의 *열거값*(지반유형→프리셋 등)뿐이며 그마저 짝 파일과 나란히 둔다. + * 2. 반올림·경계 판정 상수까지 파이썬과 같은 값을 쓴다. + * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. + * ========================================================================== */ + +import type { BermSpec } from "./common_util_cross_berm"; +import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; +// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). +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 = { + soil: "soil", + ripping_rock: "rock", + blasting_rock: "rock", +}; +/** 단면유형. 짝: config `SECTION_MODES`. 좌=양(+)offset, 우=음(-)offset. */ +const SECTION_MODES = ["left_cut", "right_cut", "both_cut", "both_fill"]; +/** 짝: config `SECTION_DITCH_SIDES` / `SECTION_DITCH_TYPES`. */ +const DITCH_SIDES = ["left", "right"]; +const DITCH_TYPES = ["standard", "l_type"]; + +/** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */ +const SLOPE_CLOSE_TOLERANCE_M = 0.01; + +export interface CrossGroundSample { + offset_m?: number | null; + elevation_m?: number | null; + valid?: boolean; +} + +/** `sections/context.standard_cross_section` 한 그룹의 모양(서버가 config 에서 내려 준다). */ +export interface StandardGroupSpec { + road_width_m?: number; + shoulder_left_m?: number; + shoulder_right_m?: number; + ditch?: { top_width_m?: number; bottom_width_m?: number; depth_m?: number }; + ditch_l_type?: { width_m?: number; depth_m?: number }; + cross_slope_pct?: { min?: number; max?: number }; + fill_slope_ratio?: number; + cut_slope_ratio?: number; + pavement_thickness_m?: number; +} + +/** 프리셋 키(soil/rock/paved) → 그룹 값. 세션 편집값도 같은 모양이다. */ +export type StandardCrossSectionSpec = Record; + +export interface CrossDesignOptions { + groundType: string; + sectionMode: string; + ditchSide?: string | null; + ditchType?: string; + paved?: boolean; + /** 표준단면 수치(필수) — config 기본값 또는 그 위에 얹은 세션 편집값. */ + standard: StandardCrossSectionSpec; + rockBoundaryOffsetM?: number | null; + twoStageSlope?: boolean; + /** 이 측점만 쓰는 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값. 없으면 표준값. + * 짝: 파이썬 `compute_cross_design(cut_slope_ratio=…)`. */ + cutSlopeRatio?: number | null; + 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 { + offset_m: number; + elevation_m: number; +} + +export interface CrossDesignResult { + ground_type: string; + geometry_preset: string; + section_mode: string; + ditch_side: string; + ditch_type: string | null; + cut_slope_ratio: number; + soil_cut_slope_ratio: number; + two_stage_slope: boolean; + 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; + paved: boolean; + road_edges: { left: CrossDesignEdge; right: CrossDesignEdge }; + carriageway_edges: { left: CrossDesignEdge; right: CrossDesignEdge }; + design_elevation_m: number; + cut_area_m2: number; + cut_soil_area_m2: number; + cut_rock_area_m2: number; + cut_rock_kind: string | null; + fill_area_m2: number; + slope_unclosed: boolean; + fill_ground_slope: number | null; + ditch_area_m2: number; + design_line: CrossDesignEdge[]; + /** 절토 사면 경사 구간(소단 제외). 짝: `cut_slope_segments`. + * ⚠ **지금 읽는 곳은 없다**(2026-09-07) — 임자였던 별표2 검사는 폐기됐고 저장분에도 + * 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다. 되살릴 때는 **계산해서** 쓸 것. */ + cut_slope_segments: CutSlopeSegment[]; + /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ + berm?: { width_m: number; interval_m: number; slope_deg: number }; + surface_drop_m?: number; + pavement_thickness_m?: number; + rock_boundary_offset_m?: number; +} + +/** 짝: 파이썬 `round(value, 4)`. */ +function round4(value: number): number { + return Math.round(value * 1e4) / 1e4; +} + +/** 짝: `_as_float` — 손상값·음수면 fallback. */ +function asFloat(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return fallback; + return parsed >= 0 ? parsed : fallback; +} + +/** + * 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게 + * 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다. + */ +function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): ResolvedGroup { + const group = standard[presetKey]; + if (!group) { + throw new Error(`표준횡단 설정에 '${presetKey}' 그룹이 없어 횡단 설계를 계산할 수 없습니다.`); + } + const ditch = group.ditch ?? {}; + const lDitch = group.ditch_l_type ?? {}; + const slope = group.cross_slope_pct ?? {}; + return { + road_width_m: asFloat(group.road_width_m, 0), + shoulder_left_m: asFloat(group.shoulder_left_m, 0), + shoulder_right_m: asFloat(group.shoulder_right_m, 0), + ditch_top_width_m: asFloat(ditch.top_width_m, 0), + ditch_bottom_width_m: asFloat(ditch.bottom_width_m, 0), + ditch_depth_m: asFloat(ditch.depth_m, 0), + // 짝 파일의 `base.get("width_m", 0.5)`와 같은 최후 기본값. + l_ditch_width_m: asFloat(lDitch.width_m, 0.5), + l_ditch_depth_m: asFloat(lDitch.depth_m, 0.1), + // 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값). + cross_slope_pct: asFloat(slope.min, 0), + fill_slope_ratio: asFloat(group.fill_slope_ratio, 0), + cut_slope_ratio: asFloat(group.cut_slope_ratio, 0), + pavement_thickness_m: asFloat(group.pavement_thickness_m, 0.2), + }; +} + +/** 짝: `_resolve_ditch_side`. */ +function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string { + if (sectionMode === "left_cut") return "left"; + if (sectionMode === "right_cut") return "right"; + if (ditchSide && DITCH_SIDES.includes(ditchSide)) return ditchSide; + return "left"; +} + +/** + * 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). + * + * 짝 파일은 앞에서부터 훑지만 여기서는 이진탐색으로 같은 구간을 고른다 — 사면·지반 + * 교차 행진이 이 함수를 수만 번 부르는데 브라우저에서는 그 비용이 조작 속도에 바로 + * 드러나기 때문이다. **결과는 완전히 같다**(같은 구간, 같은 선형보간). + */ +function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number { + const last = valid.length - 1; + return (offsetM: number): number => { + if (offsetM <= valid[0][0]) return valid[0][1]; + if (offsetM >= valid[last][0]) return valid[last][1]; + let low = 1; + let high = last; + while (low < high) { + const mid = (low + high) >> 1; + if (valid[mid][0] < offsetM) low = mid + 1; + else high = mid; + } + const [x1, z1] = valid[low]; + const [x0, z0] = valid[low - 1]; + const span = x1 - x0; + if (span <= 0) return z1; + return z0 + (z1 - z0) * ((offsetM - x0) / span); + }; +} + +/** + * 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다. + * + * `samples` 는 지반선 원시 샘플, `designElevationM` 은 중심선 계획고(노면고)다. + * 계산 불가(계획고 없음·샘플 부족·잘못된 유형)면 던진다 — 호출부가 그 측점을 건너뛴다. + */ +export function computeCrossDesign( + samples: CrossGroundSample[], + designElevationM: number | null | undefined, + options: CrossDesignOptions, +): CrossDesignResult { + const groundType = options.groundType; + const sectionMode = options.sectionMode; + const ditchType = options.ditchType ?? "standard"; + const paved = Boolean(options.paved); + if (!(groundType in GROUND_TYPE_PRESET)) { + throw new Error(`지원하지 않는 지반유형입니다: ${groundType}`); + } + if (!SECTION_MODES.includes(sectionMode)) { + throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); + } + if (!DITCH_TYPES.includes(ditchType)) { + throw new Error(`지원하지 않는 측구 형식입니다: ${ditchType}`); + } + if (designElevationM === null || designElevationM === undefined) { + throw new Error("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다."); + } + const drop = Math.max(options.surfaceDropM ?? 0, 0); + const centerElevation = designElevationM - drop; + + const presetKey = GROUND_TYPE_PRESET[groundType]; + if (ditchType === "l_type" && presetKey !== "rock") { + throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다."); + } + let group = resolveGroup(presetKey, options.standard); + // 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + // ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 값만 베껴 + // 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + const userCutRatio = options.cutSlopeRatio; + if (typeof userCutRatio === "number" && Number.isFinite(userCutRatio) && userCutRatio > 0) { + group = { ...group, cut_slope_ratio: userCutRatio }; + } + const pavedGroup = resolveGroup("paved", options.standard); + // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. + const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; + const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide); + + const valid: Array<[number, number]> = samples + .filter( + (sample) => + sample.valid !== false && + sample.offset_m !== null && + sample.offset_m !== undefined && + sample.elevation_m !== null && + sample.elevation_m !== undefined, + ) + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]) + .sort((a, b) => a[0] - b[0]); + if (valid.length < 2) { + throw new Error("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다."); + } + + const groundAt = groundInterpolator(valid); + const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null; + // 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, + ditchType, + crossSlopePct, + groundAt, + soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio, + rockBoundaryOffsetM, + twoStageSlope: enableTwoStage, + ditchEnabled: options.ditchEnabled ?? null, + berm: options.berm ?? null, + }); + + // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). + const minOffset = valid[0][0]; + const maxOffset = valid[valid.length - 1][0]; + const mergedSet = new Set(valid.map(([offset]) => round6(offset))); + for (const point of geometry.breakpoints()) { + if (point >= minOffset && point <= maxOffset) mergedSet.add(round6(point)); + } + const merged = [...mergedSet].sort((a, b) => a - b); + + const offsets: number[] = []; + const diffs: number[] = []; + const designLine: CrossDesignEdge[] = []; + for (const offsetM of merged) { + const groundM = groundAt(offsetM); + const designZ = geometry.designZ(offsetM, groundM); + offsets.push(offsetM); + diffs.push(groundM - designZ); + designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) }); + } + + // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). + const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); + const fillGroundSlope = geometry.fillGroundSlope(); + const slopeUnclosed = + diffs.length > 0 && + (Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M || + Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M); + + // 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암. + let cutSoilArea: number; + let cutRockArea: number; + let cutRockKind: string | null; + if (presetKey !== "rock") { + cutSoilArea = cutArea; + cutRockArea = 0; + cutRockKind = null; + } else if (rockBoundaryOffsetM === null) { + cutSoilArea = 0; + cutRockArea = cutArea; + cutRockKind = groundType; + } else { + [cutSoilArea, cutRockArea] = splitCutAreas(offsets, diffs, Math.abs(rockBoundaryOffsetM)); + cutRockKind = groundType; + } + + // 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사. + let ditchArea: number; + let ditchSpec: Record; + if (!geometry.hasDitch) { + ditchArea = 0; + ditchSpec = { type: "none" }; + } else if (ditchType === "l_type") { + ditchArea = (group.l_ditch_width_m * group.l_ditch_depth_m) / 2; + ditchSpec = { + type: "l_type", + width_m: group.l_ditch_width_m, + depth_m: group.l_ditch_depth_m, + }; + } else { + ditchArea = ((group.ditch_top_width_m + group.ditch_bottom_width_m) / 2) * group.ditch_depth_m; + ditchSpec = { + type: "standard", + top_width_m: group.ditch_top_width_m, + bottom_width_m: group.ditch_bottom_width_m, + depth_m: group.ditch_depth_m, + }; + } + + // 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2). + let resolvedMode: string; + if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut"; + else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") + resolvedMode = "both_fill"; + else if (geometry.leftRole === "cut") resolvedMode = "left_cut"; + else resolvedMode = "right_cut"; + + const result: CrossDesignResult = { + ground_type: groundType, + geometry_preset: presetKey, + section_mode: resolvedMode, + ditch_side: resolvedDitchSide, + ditch_type: geometry.hasDitch ? ditchType : null, + cut_slope_ratio: round4(geometry.cutRatio), + soil_cut_slope_ratio: round4(geometry.soilCutRatio), + // 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`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(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, + paved, + road_edges: { + left: { + offset_m: round4(geometry.leftExtent), + elevation_m: round4(geometry.roadZ(geometry.leftExtent)), + }, + right: { + offset_m: round4(-geometry.rightExtent), + elevation_m: round4(geometry.roadZ(-geometry.rightExtent)), + }, + }, + carriageway_edges: { + left: { + offset_m: round4(geometry.halfRoadLeft), + elevation_m: round4(geometry.roadZ(geometry.halfRoadLeft)), + }, + right: { + offset_m: round4(-geometry.halfRoadRight), + elevation_m: round4(geometry.roadZ(-geometry.halfRoadRight)), + }, + }, + design_elevation_m: round4(centerElevation), + cut_area_m2: round4(cutArea), + cut_soil_area_m2: round4(cutSoilArea), + cut_rock_area_m2: round4(cutRockArea), + cut_rock_kind: cutRockKind, + fill_area_m2: round4(fillArea), + slope_unclosed: slopeUnclosed, + 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 (options.berm) { + // 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정 + // (`fillSlopeLengths`)이 이 값을 보고** 사면을 도막으로 끊어 잰다(계획서 3-9). + result.berm = { + width_m: round4(options.berm.widthM), + interval_m: round4(options.berm.intervalM), + slope_deg: round4(options.berm.slopeDeg), + }; + } + if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); + if (presetKey === "rock" && rockBoundaryOffsetM !== null) { + result.rock_boundary_offset_m = round4(rockBoundaryOffsetM); + } + return result; +} + +/** 짝: 파이썬 `round(offset, 6)` — 병합 격자 중복 제거 기준. */ +function round6(value: number): number { + return Math.round(value * 1e6) / 1e6; +} diff --git a/common_util/common_util_cross_design_areas.ts b/common_util/common_util_cross_design_areas.ts new file mode 100644 index 00000000..081cea98 --- /dev/null +++ b/common_util/common_util_cross_design_areas.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * common_util_cross_design_areas.ts + * 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리. + * + * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠ + * 짝: `B06_Section/B06_Section_Engine_Areas.py` + * 같은 입력에 같은 값을 내야 한다. 회귀 테스트가 두 구현을 실제로 비교한다: + * `tmp/tests/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것. + * 왜 두 벌인가: 사용자 조작 중 계산은 브라우저 안에서 끝나야 하고(2026-09-03 사용자 + * 확정), 저장·확정·도면 산출은 서버가 정본으로 다시 계산하기 때문이다. + * + * 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다. + * ========================================================================== */ + +/** + * 오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 [절토, 성토] 면적을 낸다. + * + * diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서 + * 나눠 절·성토가 섞이지 않게 한다. + */ +export function trapezoidAreas(offsets: number[], diffs: number[]): [number, number] { + let cutArea = 0; + let fillArea = 0; + for (let index = 1; index < offsets.length; index += 1) { + const x0 = offsets[index - 1]; + const x1 = offsets[index]; + const d0 = diffs[index - 1]; + const d1 = diffs[index]; + const width = x1 - x0; + if (width <= 0) continue; + if (d0 === 0 && d1 === 0) continue; + if (d0 * d1 < 0) { + // 부호 변화: 영교점에서 두 삼각형으로 분리 + const zeroRatio = d0 / (d0 - d1); + const xZero = x0 + width * zeroRatio; + const leftArea = 0.5 * (xZero - x0) * Math.abs(d0); + const rightArea = 0.5 * (x1 - xZero) * Math.abs(d1); + if (d0 > 0) { + cutArea += leftArea; + fillArea += rightArea; + } else { + fillArea += leftArea; + cutArea += rightArea; + } + continue; + } + const area = 0.5 * (d0 + d1) * width; + if (area >= 0) cutArea += area; + else fillArea += -area; + } + return [cutArea, fillArea]; +} + +/** + * 절토 면적을 암반 경계선 기준으로 [토사, 암반]으로 나눈다. + * + * 암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께 + * `t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에 + * 대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상 + * `max(d, 0)`이라 `trapezoidAreas`의 절토 면적과 정확히 일치한다. + * + * 두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야 + * 사다리꼴 적분이 근사가 아닌 정확값이 된다. + */ +export function splitCutAreas( + offsets: number[], + diffs: number[], + soilDepthM: number, +): [number, number] { + const t0 = Math.max(soilDepthM, 0); + let soilArea = 0; + let rockArea = 0; + for (let index = 1; index < offsets.length; index += 1) { + const x0 = offsets[index - 1]; + const x1 = offsets[index]; + const d0 = diffs[index - 1]; + const d1 = diffs[index]; + const width = x1 - x0; + if (width <= 0) continue; + const ratios = [0, 1]; + for (const level of [0, t0]) { + if ((d0 - level) * (d1 - level) < 0) ratios.push((level - d0) / (d1 - d0)); + } + ratios.sort((a, b) => a - b); + for (let step = 1; step < ratios.length; step += 1) { + const ratioA = ratios[step - 1]; + const ratioB = ratios[step]; + const span = width * (ratioB - ratioA); + if (span <= 0) continue; + const dA = d0 + (d1 - d0) * ratioA; + const dB = d0 + (d1 - d0) * ratioB; + soilArea += ((Math.min(Math.max(dA, 0), t0) + Math.min(Math.max(dB, 0), t0)) / 2) * span; + rockArea += ((Math.max(dA - t0, 0) + Math.max(dB - t0, 0)) / 2) * span; + } + } + return [soilArea, rockArea]; +} diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts new file mode 100644 index 00000000..adbe7b67 --- /dev/null +++ b/common_util/common_util_cross_design_geometry.ts @@ -0,0 +1,461 @@ +/* ============================================================================= + * common_util_cross_design_geometry.ts + * 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다. + * + * ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다. + * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. + * ========================================================================== */ + +import { + type BermSpec, + cutProfilePoints, + elevationAt, + fillProfilePoints, +} 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 CROSS_MAX_M = 500; + +export interface ResolvedGroup { + road_width_m: number; + shoulder_left_m: number; + shoulder_right_m: number; + ditch_top_width_m: number; + ditch_bottom_width_m: number; + ditch_depth_m: number; + l_ditch_width_m: number; + l_ditch_depth_m: number; + cross_slope_pct: number; + fill_slope_ratio: number; + cut_slope_ratio: number; + pavement_thickness_m: number; +} + +/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ +export function sideRole(sectionMode: string): [string, string] { + if (sectionMode === "left_cut") return ["cut", "fill"]; + if (sectionMode === "right_cut") return ["fill", "cut"]; + if (sectionMode === "both_cut") return ["cut", "cut"]; + if (sectionMode === "both_fill") return ["fill", "fill"]; + 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; + cutRatio: number; + fillRatio: number; + leftRole: string; + rightRole: string; + ditchSide: string; + soilCutRatio: number; + twoStage: boolean; + ditchType: string; + slopePerOffset: number; + hasDitch: boolean; + ditchPoints: Array<[number, number]> = []; + private groundAt: ((offsetM: number) => number) | null; + private rockOffset: number; + /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ + berm: BermSpec | null = null; + private cutPointsCache = new Map>(); + private fillPointsCache = new Map>(); + private cutCross = new Map(); + private fillCross = new Map(); + + constructor(params: { + designElevationM: number; + group: ResolvedGroup; + sectionMode: string; + ditchSide: string; + ditchType: string; + crossSlopePct: number; + groundAt: ((offsetM: number) => number) | null; + soilCutRatio: number | null; + 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.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); + [this.leftRole, this.rightRole] = sideRole(params.sectionMode); + this.ditchSide = params.ditchSide; + this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6); + this.twoStage = Boolean( + 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 규약). + const slope = params.crossSlopePct / 100; + this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; + + // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. + const groundAt = params.groundAt; + if (groundAt !== null) { + this.leftRole = + groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill"; + this.rightRole = + groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill"; + } + + // 측구 생성 여부(D-1). + if (params.sectionMode === "both_fill") { + this.hasDitch = false; + } else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) { + this.hasDitch = params.ditchEnabled; + } else if (groundAt !== null) { + const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3; + } else { + this.hasDitch = true; + } + + // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). + const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + const outward = params.ditchSide === "left" ? 1 : -1; + const edgeZ = this.roadZ(edgeOffset); + if (this.hasDitch) { + if (params.ditchType === "l_type") { + // L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. + const width = group.l_ditch_width_m; + const depth = group.l_ditch_depth_m; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * width, edgeZ - depth], + [edgeOffset + outward * width, edgeZ], + ]; + } else { + // 일반: 상단폭/저폭/깊이 사다리꼴. + const top = group.ditch_top_width_m; + const bottom = Math.min(group.ditch_bottom_width_m, top); + const depth = group.ditch_depth_m; + const inset = (top - bottom) / 2; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * inset, edgeZ - depth], + [edgeOffset + outward * (inset + bottom), edgeZ - depth], + [edgeOffset + outward * top, edgeZ], + ]; + } + } + } + + /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ + roadZ(offsetM: number): number { + return this.zCenter + this.slopePerOffset * offsetM; + } + + /** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */ + private slopeStart(side: string): [number, number] { + const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent; + const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); + if (side === this.ditchSide && this.ditchPoints.length) { + const outer = this.ditchPoints[this.ditchPoints.length - 1]; + return [Math.abs(outer[0]), outer[1]]; + } + return [edgeOffset, edgeZ]; + } + + /** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */ + private rockBoundaryZ(side: string, dist: number): number { + const signed = side === "left" ? dist : -dist; + return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; + } + + /** 짝: `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); + 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`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */ + private cutSlopeZ(side: string, dist: number): number { + return elevationAt(this.cutPoints(side), dist); + } + + /** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */ + fillPoints(side: string): Array<[number, number]> { + const cached = this.fillPointsCache.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm); + this.fillPointsCache.set(side, points); + return points; + } + + /** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */ + private fillSlopeZ(side: string, dist: number): number { + return elevationAt(this.fillPoints(side), dist); + } + + /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ + cutCrossDist(side: string): number | null { + const cached = this.cutCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.cutCross.set(side, result); + return result; + } + + /** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */ + fillCrossDist(side: string): number | null { + const cached = this.fillCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + const fillLine = this.fillSlopeZ(side, dist); + if (fillLine - this.groundAt(signed) <= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.fillCross.set(side, result); + return result; + } + + /** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */ + fillGroundSlope(): number | null { + if (this.groundAt === null) return null; + const slopes: number[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const [startDist] = this.slopeStart(side); + const endDist = this.fillCrossDist(side) ?? startDist + 10; + const run = endDist - startDist; + if (run <= 1e-6) continue; + const sign = side === "left" ? 1 : -1; + const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist)); + slopes.push(rise / run); + } + return slopes.length ? Math.min(...slopes) : null; + } + + /** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */ + designZ(offsetM: number, groundM: number): number { + const side = offsetM >= 0 ? "left" : "right"; + const extent = side === "left" ? this.leftExtent : this.rightExtent; + if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM); + // 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). + if (side === this.ditchSide && this.ditchPoints.length) { + const inner = Math.abs(this.ditchPoints[0][0]); + const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]); + if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) { + const points = this.ditchPoints; + for (let index = 1; index < points.length; index += 1) { + const x0 = Math.abs(points[index - 1][0]); + const z0 = points[index - 1][1]; + const x1 = Math.abs(points[index][0]); + const z1 = points[index][1]; + if (Math.abs(offsetM) > x1 + 1e-9) continue; + const span = x1 - x0; + if (span <= 1e-9) return z1; + return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span); + } + return points[points.length - 1][1]; + } + } + const role = side === "left" ? this.leftRole : this.rightRole; + const dist = Math.abs(offsetM); + if (role === "cut") { + const cross = this.cutCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.min(this.cutSlopeZ(side, dist), groundM); + } + const cross = this.fillCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.max(this.fillSlopeZ(side, dist), groundM); + } + + /** + * 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록. + * ⚠ 지금 읽는 곳은 없다(2026-09-07, 법정 검사 폐기). 소단 기하가 이 셈 위에 선다. + * + * 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼 + * 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다. + */ + 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 || this.berm !== null) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + 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); + } + } + } + // 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. + if (this.berm !== null) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const cross = this.fillCrossDist(side); + for (const [offset] of this.fillPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; + points.push(side === "left" ? offset : -offset); + } + } + } + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); + if (cross !== null) points.push(side === "left" ? cross : -cross); + } + return points; + } +} 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_crs.py b/common_util/common_util_crs.py index fb56efbe..ac511ef8 100644 --- a/common_util/common_util_crs.py +++ b/common_util/common_util_crs.py @@ -209,3 +209,51 @@ def crs_input_from_prj(prj_text: str) -> str | None: else: logger.warning("PRJ 좌표계의 EPSG 라벨을 찾지 못함 — 파일 WKT로 변환: %s", crs.name) return text + + +def project_prj_crs(project_root: Path) -> str | None: + """프로젝트 **작업 좌표계** — 지형 PRJ 원문. PRJ가 없으면 None. + + 작업 좌표계는 지형 PRJ 로 확정(2026-09-03 사용자 결정, 종전 동작 유지). 서피스 격자가 + 모델좌표의 주인이고 노선은 `load_design_route()` 가 이 좌표계로 옮겨 오기 때문이다. + 노선 shapefile 은 다른 좌표계로 들어온다(실측 2026-08-31 — 노선 UTM-K / 지형 동부원점). + + 기본값을 섞지 않는다 — 사다리(`resolve_project_crs`)가 다음 근거로 내려갈 수 있어야 한다. + """ + prj_path = find_project_prj(project_root) + if prj_path is None: + return None + from B04_PreProcess.B04_PreProcess_Engine_VWorld import get_epsg_from_prj + + return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + + +def resolve_project_crs( + project_root: Path, + *, + route_crs_input: str | None = None, + file_label_epsg: int | None = None, + db_epsg: int | None = None, +) -> str: + """작업 좌표계를 정하는 **단일 창구** — pyproj 입력 문자열(`EPSG:n` 또는 WKT 원문). + + 사다리(위가 이김): + ① `load_design_route()` 가 돌려준 노선 `crs_input` — 이미 작업 좌표계로 맞춰 둔 값 + ② `file_label_epsg` — **원본 파일 좌표를 그대로 해석할 때만** 넘긴다(노선 CSV 의 + `crs_epsg` 열). 그 자리에서는 파일이 스스로 밝힌 좌표계가 유일한 근거다. + ③ 지형 PRJ(`project_prj_crs`) — 작업 좌표계 정본 + ④ DB `surface_models.crs_epsg` + ⑤ 중부원점 `EPSG:5186` — 최후 폴백 + + **변환이 끝난 좌표를 다룰 때는 ②를 넘기지 않는다.** 그 라벨은 실좌표계와 다른 사례가 + 실측됐다(2026-09-01 용화 — 라벨 5179 / 실제 5176). 이 창구를 두기 전에는 같은 사다리가 + 네 곳에 서로 다른 모양으로 흩어져 있었다. + """ + if route_crs_input: + return route_crs_input + if file_label_epsg: + return f"EPSG:{file_label_epsg}" + prj_crs = project_prj_crs(project_root) + if prj_crs: + return prj_crs + return f"EPSG:{db_epsg or 5186}" diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts new file mode 100644 index 00000000..1b67957e --- /dev/null +++ b/common_util/common_util_culvert_sets.ts @@ -0,0 +1,363 @@ +/* ============================================================================= + * 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) { + // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬 + // `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면 + // 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**. + section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = { + ...spec, + chainage_m: pipeChainage, + }; + 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_dev_unlock.py b/common_util/common_util_dev_unlock.py new file mode 100644 index 00000000..41736b81 --- /dev/null +++ b/common_util/common_util_dev_unlock.py @@ -0,0 +1,165 @@ +"""개발환경 전용 — **확정을 거치지 않고 다음 단계로 넘어가게** 하는 자리. + +왜 있나 + 프로그램은 단계마다 [확정]을 해야 다음 페이지가 열린다. 그래서 **상세 설계를 확정하기 + 전에는 B08·B09 를 아예 볼 수 없다.** 화면 검증을 하려면 매번 남의 프로젝트 확정 상태에 + 매달려야 했다(2026-09-08 실제로 그 자리에서 두 창이 막혔다). + +⚠⚠ **계산을 대신 돌리지 않는다 — 잠금만 푼다.** + [확정]은 **전 측점을 다시 계산해 정본에 쓰는 것**이다. 여기서 그 계산을 흉내 내면 + 「확정 안 했는데 확정된 값」이 생겨 **막힌 것보다 더 나쁘다.** 그래서 이 모듈이 바꾸는 + 것은 `project_workflow_stages.state` **한 칸뿐**이다. 값이 없으면 B08 이 + 「미확보」로 뜨는 것이 **정상이고 그것이 옳은 화면**이다. + +⚠ **문은 서버가 정본이다.** + 화면에서 단추를 숨기는 것만으로는 API 가 그대로 뚫려 있다. 그래서 이 모듈이 + `ENVIRONMENT` 를 보고 **운영에서는 아예 거절한다.** 화면 쪽 `import.meta.env.DEV` 는 + 보조일 뿐이다. + +⚠ **되돌릴 수 있어야 한다.** + 푼 단계와 그 **이전 상태**를 `dev_unlock` 칸에 적어 두고, 되돌리기가 그대로 복원한다. + 안 그러면 검증용 프로젝트가 이상한 상태로 굳는다. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any + +import aiomysql + +from common_util.common_util_workflow_state import STAGE_KEYS +from config.config_system import ENVIRONMENT + +#: 개발환경으로 보는 값. 그 밖(staging·production)에서는 이 기능이 아예 안 돈다. +DEV_ENVIRONMENTS = frozenset({"development", "dev", "local", "test"}) + +#: 되돌리기용 기록을 남기는 자리. 프로젝트 저장 폴더가 아니라 **DB 안**에 둔다 — +#: 상태와 같은 곳에 있어야 둘이 어긋나지 않는다. +UNLOCK_MESSAGE_PREFIX = "DEV_UNLOCK:" + + +class DevUnlockDisabled(RuntimeError): + """개발환경이 아니어서 거절함. **이 예외가 곧 운영 쪽 문**이다.""" + + +def is_dev_environment() -> bool: + """지금 환경에서 이 기능을 켜도 되는가.""" + return str(ENVIRONMENT or "").strip().lower() in DEV_ENVIRONMENTS + + +def require_dev_environment() -> None: + """개발환경이 아니면 **여기서 멈춘다.** 화면이 아니라 서버가 막는 자리다.""" + if not is_dev_environment(): + raise DevUnlockDisabled(f"개발환경에서만 쓸 수 있습니다 (지금 환경: {ENVIRONMENT}).") + + +async def unlock_stages( + cursor: aiomysql.DictCursor, project_id: str, up_to_stage: int +) -> dict[str, Any]: + """`up_to_stage` 까지의 단계를 **상태만** COMPLETE 로 만든다. + + ⚠ 계산·저장은 하지 않는다. 되돌릴 수 있게 **이전 상태를 함께 적어 둔다.** + 이미 COMPLETE 인 단계는 건드리지 않는다 — 되돌릴 때 남의 확정까지 풀면 안 된다. + """ + require_dev_environment() + if not 0 <= up_to_stage < len(STAGE_KEYS): + raise ValueError(f"단계 번호가 범위를 벗어났습니다: {up_to_stage}") + + await cursor.execute( + """ + SELECT stage_no, state, message + FROM project_workflow_stages + WHERE project_id = %s AND stage_no <= %s + ORDER BY stage_no ASC + """, + (project_id, up_to_stage), + ) + rows = await cursor.fetchall() + + changed: list[dict[str, Any]] = [] + now = datetime.utcnow() + for row in rows: + if row["state"] == "COMPLETE": + continue # 진짜로 확정된 단계 — 손대지 않는다. + changed.append({"stage_no": int(row["stage_no"]), "state": row["state"]}) + note = f"{UNLOCK_MESSAGE_PREFIX}{row['state']}" + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = 'COMPLETE', + progress_percent = 100, + completed_at = %s, + message = %s + WHERE project_id = %s AND stage_no = %s + """, + (now, note, project_id, int(row["stage_no"])), + ) + return { + "unlocked": changed, + "up_to_stage": up_to_stage, + "note": ( + "확정을 건너뛰고 잠금만 풀었습니다 — 계산은 돌지 않았습니다. " + "값이 비어 보이는 것은 정상입니다." + ), + } + + +async def relock_stages(cursor: aiomysql.DictCursor, project_id: str) -> dict[str, Any]: + """우회로 푼 단계를 **원래 상태로 되돌린다.** + + ⚠ `DEV_UNLOCK:` 표시가 붙은 단계만 되돌린다 — 그 표시가 없으면 **진짜 확정**이라 + 건드리면 안 된다. 표시 뒤에 적어 둔 옛 상태를 그대로 복원한다. + """ + require_dev_environment() + await cursor.execute( + """ + SELECT stage_no, message + FROM project_workflow_stages + WHERE project_id = %s AND message LIKE %s + ORDER BY stage_no ASC + """, + (project_id, f"{UNLOCK_MESSAGE_PREFIX}%"), + ) + rows = await cursor.fetchall() + + restored: list[dict[str, Any]] = [] + for row in rows: + previous = str(row["message"])[len(UNLOCK_MESSAGE_PREFIX) :].strip() or "NOT_STARTED" + restored.append({"stage_no": int(row["stage_no"]), "state": previous}) + await cursor.execute( + """ + UPDATE project_workflow_stages + SET state = %s, + progress_percent = 0, + completed_at = NULL, + message = NULL + WHERE project_id = %s AND stage_no = %s + """, + (previous, project_id, int(row["stage_no"])), + ) + return {"relocked": restored} + + +async def unlock_status(cursor: aiomysql.DictCursor, project_id: str) -> dict[str, Any]: + """지금 우회로 열려 있는 단계 목록. **화면이 띄울 안내의 근거**다.""" + await cursor.execute( + """ + SELECT stage_no, message + FROM project_workflow_stages + WHERE project_id = %s AND message LIKE %s + ORDER BY stage_no ASC + """, + (project_id, f"{UNLOCK_MESSAGE_PREFIX}%"), + ) + rows = await cursor.fetchall() + return { + "dev_environment": is_dev_environment(), + "bypassed_stages": [int(row["stage_no"]) for row in rows], + } + + +def as_json(payload: dict[str, Any]) -> str: + """로그용 — 한글이 깨지지 않게.""" + return json.dumps(payload, ensure_ascii=False) diff --git a/common_util/common_util_dev_unlock_router.py b/common_util/common_util_dev_unlock_router.py new file mode 100644 index 00000000..852301d5 --- /dev/null +++ b/common_util/common_util_dev_unlock_router.py @@ -0,0 +1,113 @@ +"""개발환경 전용 — 「확정 없이 다음으로」 API. + +⚠ **문은 여기가 정본이다.** 화면에서 단추를 숨겨도 API 가 열려 있으면 아무 소용이 없다. + 그래서 세 입구 모두 `require_dev_environment()` 를 먼저 부르고, 운영에서는 **403** 으로 + 거절한다. 프론트의 `import.meta.env.DEV` 는 보조일 뿐이다. + +⚠ **계산을 대신 돌리지 않는다.** 여기서 바뀌는 것은 `project_workflow_stages.state` + 한 칸뿐이다. 자세한 까닭은 `common_util_dev_unlock` 의 머리말을 볼 것. +""" + +from __future__ import annotations + +import logging +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from common_util.common_util_dev_unlock import ( + DevUnlockDisabled, + relock_stages, + unlock_stages, + unlock_status, +) +from config.config_db import run_with_connection + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/projects", tags=["DEV Unlock"]) + +#: 기본은 B08(수량산출)까지 — 검증이 가장 자주 막히던 자리다. +DEFAULT_UP_TO_STAGE = 5 + + +class UnlockBody(BaseModel): + """어디까지 열 것인가. 안 주면 B08 까지.""" + + up_to_stage: int = DEFAULT_UP_TO_STAGE + + +async def _with_cursor(connection: aiomysql.Connection, call: Any, *args: Any) -> Any: + """쓰기라 **한 커넥션·한 트랜잭션**으로 묶는다(`run_with_connection` 주석 참조).""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + result = await call(cursor, *args) + await connection.commit() + return result + + +@router.get("/{project_id}/dev/unlock") +async def get_unlock_status(project_id: UUID) -> JSONResponse: + """지금 우회로 열려 있는 단계. **화면 안내의 근거**다.""" + + async def call(connection: aiomysql.Connection) -> dict[str, Any]: + async with connection.cursor(aiomysql.DictCursor) as cursor: + return await unlock_status(cursor, str(project_id)) + + try: + payload = await run_with_connection(call) + except Exception: + logger.exception("개발 우회 상태 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "우회 상태를 읽지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", **payload}) + + +@router.post("/{project_id}/dev/unlock") +async def post_unlock(project_id: UUID, body: UnlockBody | None = None) -> JSONResponse: + """확정을 건너뛰고 **잠금만** 푼다. 개발환경이 아니면 403.""" + up_to = (body or UnlockBody()).up_to_stage + + async def call(connection: aiomysql.Connection) -> dict[str, Any]: + return await _with_cursor(connection, unlock_stages, str(project_id), up_to) + + try: + payload = await run_with_connection(call) + except DevUnlockDisabled as error: + return JSONResponse(status_code=403, content={"status": "error", "message": str(error)}) + except ValueError as error: + return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("개발 우회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "단계를 열지 못했습니다."}, + ) + logger.info("DEV 우회: project_id=%s 까지=%s", project_id, up_to) + return JSONResponse(content={"status": "success", **payload}) + + +@router.delete("/{project_id}/dev/unlock") +async def delete_unlock(project_id: UUID) -> JSONResponse: + """우회로 연 단계를 **원래 상태로 되돌린다.** 진짜 확정은 안 건드린다.""" + + async def call(connection: aiomysql.Connection) -> dict[str, Any]: + return await _with_cursor(connection, relock_stages, str(project_id)) + + try: + payload = await run_with_connection(call) + except DevUnlockDisabled as error: + return JSONResponse(status_code=403, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception("개발 우회 되돌리기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "단계를 되돌리지 못했습니다."}, + ) + logger.info("DEV 우회 되돌림: project_id=%s", project_id) + return JSONResponse(content={"status": "success", **payload}) diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index bb71200f..662be1ed 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -3,7 +3,7 @@ 두 화면이 같은 관 목록과 같은 세부유역을 보여 주려면 **입력이 한 글자도 달라선 안 된다** (2026-08-01 사용자 지시). 그래서 노선·종단 Z·좌표계를 여기 한 곳에서 만들어 양쪽에 넘긴다. -노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 업로드한 원청 계획노선 CSV**다. B04 격자 +노선 기준선은 B05가 푼 최적 경로가 아니라 **B03이 받은 원청 계획노선(정본)**이다. B04 격자 해석이 그 노선으로 도로 셀을 구웠으므로, 다른 노선의 누가거리를 쓰면 도로 셀과 관 위치가 어긋난다. 종단 Z만 상황에 따라 갈아 끼운다 → [[common_util_route_profile]]. """ @@ -27,6 +27,7 @@ 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, @@ -36,7 +37,7 @@ from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_pr 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__) @@ -57,6 +58,13 @@ class DrainageContext: crs: str = "EPSG:5186" route_id: int | None = None to_lonlat: Callable[[float, float], tuple[float, float]] = lambda x, y: (x, y) + # 1단계에서 확정한 지표면 선택(source_filter·method·smooth). B07 라이다 계획평면도가 + # 어느 DTM 격자로 음영기복을 만들지 고르는 데 쓴다(2026-09-04). + 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]: @@ -64,32 +72,43 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non 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, @@ -99,8 +118,8 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non ) # 노선을 실제로 담고 있는 좌표계를 쓴다 — `load_design_route()`가 .prj 좌표계로 - # 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다. - crs = planned.crs_input or f"EPSG:{planned.epsg or db_epsg or 5186}" + # 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다(라벨은 안 씀). + crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=db_epsg) transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True) return ( DrainageContext( @@ -111,6 +130,7 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non crs=crs, route_id=int(route["id"]) if route else None, to_lonlat=lambda x, y: transformer.transform(x, y), + surface_params=dict(surface_params), ), "", ) diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index ceb52b29..8e2a2f40 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -34,7 +34,7 @@ import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_hotspots from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES -from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import largest_ring, polygonize_labels +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygon_parts, polygonize_labels from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_BOX, @@ -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 @@ -104,7 +107,10 @@ class WatershedBasin: chainage_m: float outlet_x: float outlet_y: float - boundary_xy: list[tuple[float, float]] = field(default_factory=list) + # 유역 경계 — 조각마다 [외곽 링, 구멍 링...]. 도넛(아래 유역이 위 유역을 감싼 경우)과 + # 떨어진 조각을 그대로 싣는다. 단일 링만 쓰던 시절에는 이 둘이 소실돼 화면에서 중첩· + # 빈공간으로 보였다(2026-09-03). + boundary_parts: list[list[list[tuple[float, float]]]] = field(default_factory=list) area_m2: float = 0.0 relief_m: float = 0.0 flow_length_m: float = 0.0 @@ -123,6 +129,16 @@ class WatershedBasin: recommended_facility: str = "pipe" recommended_diameter_mm: int | None = None + @property + def boundary_xy(self) -> list[tuple[float, float]]: + """가장 넓은 조각의 외곽 링 — 링 하나만 받는 옛 소비처를 위한 자리.""" + return self.boundary_parts[0][0] if self.boundary_parts else [] + + @property + def boundary_rings(self) -> list[list[tuple[float, float]]]: + """조각 구분 없이 편 링 목록 — 캔버스는 even-odd로 한 번에 채운다.""" + return [ring for part in self.boundary_parts for ring in part] + @dataclass class RoadRouting: @@ -217,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( @@ -253,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(): @@ -460,46 +500,43 @@ def assign_road_cells_to_pipes( ) -> np.ndarray: """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. - 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 - 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 - 가장 가까운 관이 받는 것으로 본다. + 노면 물은 측구를 타고 종단 내리막으로 흐르므로 종단 계획선을 1차원 지형으로 본다. + 1차원에서는 물이 **마루(구간 최고점)를 넘지 못한다** — 이웃한 두 관 사이의 최고점이 + 곧 분수령이고, 그 왼쪽은 앞 관이, 오른쪽은 뒤 관이 받는다. 첫 관 앞과 마지막 관 뒤는 + 그 관이 받는다. + + 옛 방식(한 칸 이웃만 보는 국소 하강 + 관 없는 저점은 최근접 관)은 계획고의 미세 + 요철에 걸려 멈췄다. 용화 실측: 측점 2,138개 중 1,898개(88.8%)가 저점에 갇혀 흐름이 + 아니라 **누가거리 최근접**으로 배정됐고, 그 결과 도로 셀 43.2%가 자기보다 높은 관에 + 배정됐다(최대 6.82m 오르막). 마루 기준은 미세 요철을 타지 않으므로 오르막 배정이 + 구조적으로 생기지 않는다(2026-09-03 사용자 확정). """ total_length = vertices[-1].chainage_m step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) stations = np.arange(0.0, total_length + step, step) - heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) - pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) - - # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). - back_z = np.full(stations.size, np.inf) - back_z[1:] = heights[:-1] - forward_z = np.full(stations.size, np.inf) - forward_z[:-1] = heights[1:] - go_back = (back_z < heights) & (back_z <= forward_z) - go_forward = (forward_z < heights) & ~go_back - receiver = np.arange(stations.size, dtype=np.int64) - receiver[go_back] -= 1 - receiver[go_forward] += 1 - receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 - - owner = np.full(stations.size, -1, dtype=np.int64) - owner[pipe_station] = np.arange(pipe_chainages.size) - jump = receiver - for _ in range(40): - next_jump = jump[jump] - if np.array_equal(next_jump, jump): - break - jump = next_jump - resolved = owner[jump] - # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. - orphan = resolved < 0 - if orphan.any() and pipe_chainages.size: - nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) - resolved[orphan] = nearest - slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) - return resolved[slot_station].astype(np.int32) + if pipe_chainages.size == 0: + return np.full(road_chainage.size, -1, dtype=np.int32) + + heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) + # 관 순서는 호출자가 준 그대로 돌려줘야 한다 — 누가거리로 정렬해 풀고 끝에 되돌린다. + order = np.argsort(pipe_chainages, kind="stable") + pipe_station = np.clip( + np.round(pipe_chainages[order] / step).astype(np.int64), 0, stations.size - 1 + ) + + owner = np.full(stations.size, pipe_station.size - 1, dtype=np.int64) # 마지막 관 뒤 + owner[: pipe_station[0] + 1] = 0 # 첫 관 앞 + for index in range(pipe_station.size - 1): + left = pipe_station[index] + right = pipe_station[index + 1] + if right <= left: + continue + ridge = left + int(np.argmax(heights[left : right + 1])) + owner[left : ridge + 1] = index + owner[ridge + 1 : right + 1] = index + 1 + return order[owner][slot_station].astype(np.int32) # ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── @@ -517,7 +554,11 @@ def assemble_basins( reached = routing.road_slot >= 0 labels[reached] = pipe_of_slot[routing.road_slot[reached]] - polygons = polygonize_labels(spec, labels) + # 최소면적 필터를 끈다 — 그 필터가 곧 빈공간이었다. 떨어진 조각을 100㎡ 미만이라고 + # 버리면 유역 면적과 그림이 어긋난다(실측: 용화 5.76%→1.86%, S자 3.56%→1.42%, + # 조각 유역 0→2·0→3 복원). 링 목록이 조각을 싣게 된 뒤로는 버릴 이유가 없고, 좌표점은 + # 583→622·806→829로 거의 늘지 않는다(2026-09-03). 남은 오차는 simplify(2.0m) 몫. + polygons = polygonize_labels(spec, labels, min_area_m2=0.0) cell_area = spec.cell_area_m2 basins: list[WatershedBasin] = [] for order, pipe in enumerate(pipes): @@ -540,7 +581,7 @@ def assemble_basins( chainage_m=pipe.chainage_m, outlet_x=pipe.x, outlet_y=pipe.y, - boundary_xy=largest_ring(geometry) if geometry is not None else [], + boundary_parts=polygon_parts(geometry) if geometry is not None else [], area_m2=area, relief_m=relief, flow_length_m=flow_length, diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 27ce35f8..247572f4 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -9,7 +9,7 @@ 종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다. 좌표를 같이 남기는 이유(2026-08-30 사용자 지적 — "결국 노선 위에 위치해야 한다"): 관 자리를 -정한 선(계획노선 CSV)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이 +정한 선(계획노선 정본)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이 다르다**(실측 350.11m vs 354.83m). 누가거리만 남기면 읽는 쪽이 쥔 선에 따라 같은 값이 3~4m 미끄러져 관이 선 옆에 떨어진 것처럼 보인다. 좌표를 남겨 두면 어느 선으로 읽든 그 좌표를 투영해 **항상 선 위에** 앉힐 수 있다. @@ -90,7 +90,11 @@ class PipePoint: def as_dict(self) -> dict[str, Any]: # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. payload: dict[str, Any] = { - "chainage_m": round(float(self.chainage_m), 2), + # 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던 + # 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를 + # 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로 + # 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + "chainage_m": round(float(self.chainage_m), 3), "source": self.source, } if self.facility != PIPE_FACILITY_PIPE: @@ -156,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]: """저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다. @@ -210,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) @@ -416,7 +470,7 @@ def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: st """세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다). `crs`는 좌표를 WGS84로 바꿀 때 쓴 **사업지 좌표계**다. 되읽는 쪽(B07 유역도)이 같은 - 좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 CSV의 EPSG 라벨로 되돌렸다 + 좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 정본의 EPSG 라벨로 되돌렸다 (2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 딴 자리로 갔다). """ path = detail_basins_path(stored_path) diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 3420a60c..39934bf7 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -9,6 +9,7 @@ CLAUDE.md 5장(조작·데이터 흐름 정책)의 **초기값** 층이다. 자 `pipe_points.json` 편집분이 그대로 남아 초기값과 다른 결과가 나온다(2026-08-29). """ +import csv import json import shutil from pathlib import Path @@ -19,17 +20,36 @@ import aiomysql # 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다. SNAPSHOT_DIRNAME = "initial_snapshot" _DB_DUMP_NAME = "db.json" +# 설계가 쓰는 계획노선 정본 — shapefile로 온 노선도 여기서는 CSV 한 벌이다. +# 사업지(.prj) 좌표계로 옮기고 지표면 밖을 잘라 조밀화까지 끝낸 값이라, 설계 계통은 +# 이 파일만 읽으면 매번 같은 노선을 본다(2026-09-03 사용자 확정). +DESIGN_ROUTE_CSV_NAME = "planned_route.csv" # 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용). DESIGNING_LOCK_NAME = "initial_design.lock" +# 초기 설계 체인이 실패로 끝났음을 남기는 마커. +DESIGN_FAILED_NAME = "initial_design.failed" # 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부. +# +# 배수유역은 `edits/`(관 지점 편집분)만 뜨다가 **폴더 통째**로 넓혔다(2026-09-04 사용자 +# 확정: 「초기값 = 파일 입력 직후 결과 전부」). 관을 옮기면 세부유역(`04_detailed_basins`)이 +# 다시 나뉘는데 그 산출물이 스냅샷 밖이라 [초기화]가 옛 유역도를 그대로 남겼다. +# 용량은 실측 8.6MB(스냅샷 전체 3.9MB → 약 12MB)로 감당할 만하다. _FILE_TREES = ( "B05_Profile/route", "B06_Section/longitudinal", "B06_Section/cross_sections", - "B04_PreProcess/drainage/edits", + "B04_PreProcess/drainage", ) +# 배수유역을 폴더 통째로 넓히기 전(2026-09-04)에 찍힌 스냅샷이 갖고 있는 자리. +_LEGACY_DRAINAGE_TREE = "B04_PreProcess__drainage__edits" + +# 코리도(3D 예상형상) 초기값 — 체인이 만든 저장본 한 벌(2026-09-04 사용자 확정). +# 작업본 파일명에는 route id가 박히는데 [초기화]는 **새 route id**를 만드므로, 스냅샷에는 +# 번호 없는 이름으로 두었다가 복원 때 새 번호로 되돌린다. +_CORRIDOR_NAME = "initial_corridor.json" + # `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556). _CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") @@ -38,6 +58,11 @@ def snapshot_dir(project_root: Path) -> Path: return Path(project_root) / SNAPSHOT_DIRNAME +def design_route_csv_path(project_root: Path) -> Path: + """설계용 계획노선 CSV 정본의 자리.""" + return snapshot_dir(Path(project_root)) / DESIGN_ROUTE_CSV_NAME + + def designing_lock_path(project_root: Path) -> Path: """초기 설계 체인이 도는 동안만 존재하는 마커. @@ -69,6 +94,45 @@ def clear_designing(project_root: Path) -> None: pass +def design_failed_path(project_root: Path) -> Path: + """초기 설계 체인이 실패로 끝났음을 남기는 마커. + + "스냅샷이 없다"는 사실만으로는 **실패한 프로젝트**와 스냅샷 기능 이전의 **옛 + 프로젝트**를 가를 수 없다. 앞은 [초기화]가 재계산으로 얼버무리면 안 되고(부분 결과는 + 분석 안 됨과 다르지 않다, 2026-09-02 사용자 확정), 뒤는 종전 재계산 폴백이 유일한 + 수단이다. 락과 같은 자리 — 스냅샷 대상 4트리 **밖**이라 복원에 딸려 들어가지 않는다. + """ + return Path(project_root) / DESIGN_FAILED_NAME + + +def is_design_failed(project_root: Path) -> bool: + return design_failed_path(project_root).is_file() + + +def read_design_failure(project_root: Path) -> str: + """실패 사유를 읽는다. 마커가 없거나 못 읽으면 빈 문자열.""" + try: + return design_failed_path(project_root).read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def mark_design_failed(project_root: Path, reason: str) -> None: + path = design_failed_path(project_root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(reason, encoding="utf-8") + except OSError: + pass # 마커 실패가 체인을 막지는 않는다 — 안내가 덜 정확해질 뿐이다. + + +def clear_design_failed(project_root: Path) -> None: + try: + design_failed_path(project_root).unlink(missing_ok=True) + except OSError: + pass + + def discard_initial_snapshot(project_root: Path) -> bool: """초기값을 무효화한다 — 지표면·노선이 바뀌어 옛 초기값이 더는 기준이 아닐 때. @@ -117,18 +181,35 @@ def _is_json_native(value: Any) -> bool: async def save_initial_snapshot( - connection: aiomysql.Connection, project_root: Path, route_id: int + connection: aiomysql.Connection, + project_root: Path, + route_id: int, + design_route_points: list[dict[str, float]] | None = None, ) -> None: - """자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다.""" + """자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다. + + `design_route_points`는 체인이 이미 만들어 둔 계획노선 정점(사업지 좌표계·트림·조밀화 + 후)이다. 받으면 CSV 정본으로 함께 남긴다 — 노선이 shapefile로 왔더라도 설계 계통이 + 읽는 것은 이 CSV 한 벌이다. + """ root = Path(project_root) target = snapshot_dir(root) if has_initial_snapshot(root): return target.mkdir(parents=True, exist_ok=True) + if design_route_points: + _write_design_route_csv(target / DESIGN_ROUTE_CSV_NAME, design_route_points) for tree in _FILE_TREES: _copy_tree(root / tree, target / tree.replace("/", "__")) + # 코리도는 체인이 마지막에 만든다 — 있으면 함께 뜬다(없으면 브라우저 폴백 그대로). + from B05_Profile.B05_Profile_Router_Corridor import corridor_path + + corridor = corridor_path(root, route_id) + if corridor.is_file(): + shutil.copy2(corridor, target / _CORRIDOR_NAME) + async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute("SELECT * FROM routes WHERE id = %s", (route_id,)) route = await cursor.fetchone() @@ -140,6 +221,17 @@ async def save_initial_snapshot( (target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") +def _write_design_route_csv(path: Path, points: list[dict[str, float]]) -> None: + """계획노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것으로.""" + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file) + writer.writerow(("sequence", "x", "y")) + writer.writerows( + (index, round(point["x"], 4), round(point["y"], 4)) + for index, point in enumerate(points) + ) + + def wipe_edited_masters(project_root: Path) -> list[str]: """사용자 편집 정본을 걷어낸다 — 재계산으로 **진짜 초기값**을 만들기 위한 사전 정리. @@ -165,12 +257,31 @@ def wipe_edited_masters(project_root: Path) -> list[str]: return removed -def restore_snapshot_files(project_root: Path) -> None: - """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다.""" +def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> None: + """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다. + + 배수유역 범위를 넓히기 전(2026-09-04)에 찍힌 스냅샷은 `edits/`만 갖고 있다 — + 그런 프로젝트는 예전처럼 그 자리만 되돌린다. 넓힌 트리를 못 찾았다고 그냥 넘어가면 + 관 지점 편집분이 초기화 뒤에도 남는다. + """ root = Path(project_root) source = snapshot_dir(root) for tree in _FILE_TREES: - _copy_tree(source / tree.replace("/", "__"), root / tree) + stored = source / tree.replace("/", "__") + if not stored.is_dir() and tree == "B04_PreProcess/drainage": + _copy_tree(source / _LEGACY_DRAINAGE_TREE, root / "B04_PreProcess/drainage/edits") + continue + _copy_tree(stored, root / tree) + + # 코리도는 복원으로 만든 **새 route id** 이름으로 되돌린다 — 이름이 어긋나면 브라우저가 + # 저장본을 못 찾아 초기화 뒤 첫 진입마다 통째로 다시 만든다. + from B05_Profile.B05_Profile_Router_Corridor import corridor_path + + corridor = source / _CORRIDOR_NAME + if route_id is not None and corridor.is_file(): + target = corridor_path(root, int(route_id)) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(corridor, target) async def restore_initial_snapshot( 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_balance.ts b/common_util/common_util_mass_haul_balance.ts index 31c454a7..1396df92 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -38,7 +38,7 @@ * 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량 * 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다. * 현 길이 20m 되는 높이 위쪽 → 종무대 - * 거기서 70m 되는 높이까지 → 도쟈 + * 거기서 60m 되는 높이까지 → 도쟈 * 그 아래 ~ 평형선 → 덤프 * 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다. * diff --git a/common_util/common_util_mass_haul_balance_view.ts b/common_util/common_util_mass_haul_balance_view.ts index dca257ff..0933fb5a 100644 --- a/common_util/common_util_mass_haul_balance_view.ts +++ b/common_util/common_util_mass_haul_balance_view.ts @@ -442,7 +442,14 @@ function appendTransfer( * 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다 * (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다. */ -export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void { +export function appendBalanceLayer( + svg: SVGSVGElement, + plan: HaulPlan, + box: BalanceLayerBox, + /** 실제로 붙일 자리 — 그래프 칸 밖을 자르는 겹이 있으면 그쪽(2026-09-04). 재는 일은 + * `svg`(뷰박스가 필요)가 그대로 맡는다. */ + host: SVGSVGElement | SVGGElement = svg, +): void { const group = svgElement("g", { class: "b06-balance" }); appendBalanceLine(group, plan, box); @@ -482,7 +489,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala balloons.forEach((balloon, index) => bind(balloon, index)); // 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다. svg.addEventListener("click", clearAll); - svg.append(group); + host.append(group); } /** diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 957dd1c2..06149fb2 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -43,8 +43,63 @@ export interface MassHaulAxis { axisX?: number; /** 그래프 위 여백(px). 생략하면 기본(10). B05는 범례 오버레이만큼 크게 준다. */ padTop?: number; + /** + * **화면에 보이는 누가거리 구간**(m). 넘기면 Y 범위를 이 구간의 누계 토량으로 잡는다 + * (2026-09-04 사용자 지시 — 종단 그래프의 세로 자동 맞춤과 같은 창). 생략하면 예전처럼 + * 전 구간 기준 ±200㎥ 고정이다. + */ + viewRange?: { fromM: number; toM: number }; + /** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */ + window?: MassHaulWindowState; + /** + * 측점 세로선을 그리지 않는다. 곡선 전체를 한 화면에 눌러 담는 보기(토량 분배)에서는 + * 측점선이 촘촘해 곡선을 덮기만 하고 자리도 못 읽는다(2026-09-06 사용자 지시). + */ + hideStations?: boolean; } +/** + * 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 + + * 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에 + * 4~5배로 바뀌었다(실측 228 → 1,019㎥). + * + * 규칙 둘. + * ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**. + * ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다. + */ +export interface MassHaulWindowState { + /** 지금 쓰고 있는 창. */ + held: { min: number; max: number } | null; + /** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */ + settled: boolean; + /** 예약해 둔 다음 프레임(중복 예약 방지). */ + frame: number; +} + +export function createMassHaulWindowState(): MassHaulWindowState { + return { held: null, settled: true, frame: 0 }; +} + +/** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ +export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { + if (state.frame) cancelAnimationFrame(state.frame); + state.frame = 0; + if (state.settled) return; + state.frame = requestAnimationFrame(() => { + state.frame = 0; + redraw(); + }); +} + +/** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */ +const WINDOW_PAD_RATIO = 0.2; +/** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */ +const WINDOW_KEEP_FILL = 0.55; +/** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 — + * 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을 + * 더 먹었다(2026-09-04 실측). */ +const WINDOW_EASE = 0.2; + /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ export const MASS_HAUL_MIN_HEIGHT = 90; /** 패널을 건드리지 않았을 때의 유토곡선 높이. */ @@ -128,9 +183,72 @@ const VOLUME_RANGE_BASE_M3 = 200; * Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만 * 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다. */ -function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { +function volumeRange( + series: MassHaulSeries[], + viewRange?: { fromM: number; toM: number }, + state?: MassHaulWindowState, +): { min: number; max: number } { let rawMin = 0; let rawMax = 0; + if (viewRange) { + // 보이는 구간만 훑는다. 창 경계를 걸친 선분이 안에서 솟구치므로 바깥 이웃 한 점도 본다. + let found = false; + for (const entry of series) { + const points = entry.result.points; + let first = -1; + let last = -1; + for (let index = 0; index < points.length; index += 1) { + const chainage = points[index].chainage_m; + if (chainage < viewRange.fromM || chainage > viewRange.toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(points.length - 1, last + 1); + index += 1 + ) { + const volume = points[index].cumulative_volume_m3; + if (!Number.isFinite(volume)) continue; + rawMin = found ? Math.min(rawMin, volume) : volume; + rawMax = found ? Math.max(rawMax, volume) : volume; + found = true; + } + } + if (found) { + // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. + const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1); + const target = { min: rawMin - padding, max: rawMax + padding }; + if (!state) return target; + const held = state.held; + if (held) { + // 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다. + const heldSpan = held.max - held.min; + const inside = rawMin >= held.min && rawMax <= held.max; + const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL; + if (inside && fills) { + state.settled = true; + return held; + } + // 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음). + const next = { + min: held.min + (target.min - held.min) * WINDOW_EASE, + max: held.max + (target.max - held.max) * WINDOW_EASE, + }; + const tolerance = Math.max((target.max - target.min) * 0.01, 0.5); + const done = + Math.abs(next.min - target.min) <= tolerance && + Math.abs(next.max - target.max) <= tolerance; + state.held = done ? target : next; + state.settled = done; + return state.held; + } + state.held = target; + state.settled = true; + return target; + } + } for (const entry of series) { rawMin = Math.min(rawMin, entry.result.min_cumulative_m3); rawMax = Math.max(rawMax, entry.result.max_cumulative_m3); @@ -200,7 +318,8 @@ function textWidth(value: string): number { * 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다. */ function appendVolumeCallout( - svg: SVGSVGElement, + /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ + svg: SVGSVGElement | SVGGElement, point: MassHaulPoint, stationInterval: number, box: CalloutPlacement, @@ -323,10 +442,35 @@ export function createMassHaulChart( // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); - const { min, max } = volumeRange(rangeSource.length ? rangeSource : series); + const { min, max } = volumeRange( + rangeSource.length ? rangeSource : series, + axis.viewRange, + axis.window, + ); const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; + // 그래프 칸 밖으로 나간 것은 그리지 않는다(2026-09-04 사용자 보고) — 보이는 구간에 + // 세로를 맞추면 0선과 그 채움색이 눈금 상한을 넘어 **범례·토글 버튼 위까지** 칠해졌다. + // 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었다. + const plotBottom = heightPx - MASS_PAD_BOTTOM; + const clipId = `b06-mass-clip-${Math.random().toString(36).slice(2, 9)}`; + const clip = svgElement("clipPath", { id: clipId }); + clip.append( + svgElement("rect", { + x: axisX, + y: padTop, + width: Math.max(0, widthPx - axis.padRight - axisX), + height: Math.max(0, plotHeight), + }), + ); + const defs = svgElement("defs", {}); + defs.append(clip); + // 두 겹으로 나누는 이유: 측점 세로선이 면과 곡선 **사이**에 서야 한다(그리는 순서 유지). + const plotBack = svgElement("g", { "clip-path": `url(#${clipId})` }); + const plotFront = svgElement("g", { "clip-path": `url(#${clipId})` }); + svg.append(defs); + // 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다. const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1]; const axisTicks: Array<{ y: number; label: string }> = []; @@ -354,8 +498,10 @@ export function createMassHaulChart( } const zeroY = y(0); - // 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다. - axisTicks.push({ y: zeroY, label: "0㎥" }); + // 0선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에 + // 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고). + const zeroInside = zeroY >= padTop && zeroY <= plotBottom; + if (zeroInside) axisTicks.push({ y: zeroY, label: "0㎥" }); onAxis?.({ padLeft: axisX, ticks: axisTicks }); const visible = series.filter((entry) => visibleKeys.has(entry.key)); @@ -379,7 +525,7 @@ export function createMassHaulChart( const banded = visible[0]; if (banded) { const last = banded.result.points[banded.result.points.length - 1]; - svg.append( + plotBack.append( svgElement("path", { // 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다. d: @@ -390,9 +536,11 @@ export function createMassHaulChart( ); } + svg.append(plotBack); + // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(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", { @@ -424,27 +572,33 @@ export function createMassHaulChart( svg.append(marker); } - // 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다. - // Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다. - svg.append( - svgElement("line", { - x1: axisX, - y1: zeroY, - x2: widthPx - axis.padRight, - y2: zeroY, - class: "b06-masshaul__zero", - }), - svgText("0", { - x: axisX - 9, - y: zeroY + 4, - "text-anchor": "end", - class: "b06-chart__tick b06-masshaul__zero-tick", - }), - ); + // 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다. + // 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정: + // 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면 + // 사라지므로 따로 붙인다. + if (zeroInside) { + plotFront.append( + svgElement("line", { + x1: axisX, + y1: zeroY, + x2: widthPx - axis.padRight, + y2: zeroY, + class: "b06-masshaul__zero", + }), + ); + svg.append( + svgText("0", { + x: axisX - 9, + y: zeroY + 4, + "text-anchor": "end", + class: "b06-chart__tick b06-masshaul__zero-tick", + }), + ); + } // 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다. for (const entry of [...visible].reverse()) { - svg.append( + plotFront.append( svgElement("path", { d: curvePath(entry.result.points, x, y), class: seriesClass(entry, "b06-masshaul__curve"), @@ -463,7 +617,7 @@ export function createMassHaulChart( // 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다. const labelX = Math.max(epX - 8, axis.padLeft + labelWidth); const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4); - svg.append( + plotFront.append( svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }), svgElement("rect", { x: labelX - labelWidth, @@ -486,23 +640,29 @@ export function createMassHaulChart( // 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을 // 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면). if (haulPlan) { - appendBalanceLayer(svg, haulPlan, { - x, - y, - left: axis.padLeft, - right: widthPx - axis.padRight, - top: padTop, - bottom: heightPx - MASS_PAD_BOTTOM, - curveYAt, - stationInterval, - }); + appendBalanceLayer( + svg, + haulPlan, + { + x, + y, + left: axis.padLeft, + right: widthPx - axis.padRight, + top: padTop, + bottom: heightPx - MASS_PAD_BOTTOM, + curveYAt, + stationInterval, + }, + // 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다. + plotFront, + ); } // 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선. const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null; if (focus) { const focusX = x(focus.chainage_m); - svg.append( + plotFront.append( svgElement("circle", { cx: focusX, cy: y(focus.cumulative_volume_m3), @@ -510,7 +670,7 @@ export function createMassHaulChart( class: "b06-masshaul__focus", }), ); - appendVolumeCallout(svg, focus, stationInterval, { + appendVolumeCallout(plotFront, focus, stationInterval, { anchorX: focusX, anchorY: y(focus.cumulative_volume_m3), left: axis.padLeft, @@ -521,6 +681,8 @@ export function createMassHaulChart( }); } + svg.append(plotFront); + svg.append( svgElement("line", { x1: axisX, @@ -662,6 +824,17 @@ export function createMassHaulSummary( ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], ); + // 절·성토 균형 지표 — 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다(2026-09-03 + // 사용자 확정). 곡선이 0선을 넘지 않으면 평형선·운반 블록이 아예 생기지 않으므로, + // 벌룬이 없을 때 왜 없는지를 이 값 하나로 읽을 수 있어야 한다. + const finalCumulative = result.points.length + ? result.points[result.points.length - 1].cumulative_volume_m3 + : 0; + chips.push([ + L("B06_MassHaul_FinalCumulative"), + `${formatVolume(finalCumulative)}㎥`, + L("B06_MassHaul_FinalCumulative_Tip"), + ]); // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). chips.push(...haulPlanChips(haulPlan)); for (const [label, value, tip] of chips) { 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_project_settings.py b/common_util/common_util_project_settings.py new file mode 100644 index 00000000..879ce77d --- /dev/null +++ b/common_util/common_util_project_settings.py @@ -0,0 +1,220 @@ +"""프로젝트 설정 — 수량(B08)·원가(B09) 두 페이지가 함께 읽는 값 (PLAN 8-7). + +자리 + `/project_settings.json` — 루트, `project_manifest.json` 옆. + 매니페스트의 `stages` 는 **단계 산출물** 목록이고, 설정은 산출물이 아니라 **프로젝트 값**이다. + 단계 폴더에 넣으면 주인이 애매해진다. + +구획 — 페이지마다 자기 것만 쓴다 + `quantity` = B08 · `estimation` = B09. 남의 구획은 **읽기만** 한다. + `dataset_versions` 도 구획마다 따로 둔다 — 한 칸을 둘이 쓰면 저장할 때마다 서로 지운다. + + ⚠ **경계를 코드로 막는다** — `save_section()` 은 이름 붙은 한 구획만 갈아 끼우고 나머지는 + 원본 그대로 둔다. 통째로 덮는 길을 두지 않는 까닭은, 두 페이지가 같은 파일을 쓰기 때문이다 + (오늘 `main.py` 에서 같은 모양의 사고를 이미 겪었다). + +⚠ `dataset_versions` 는 **기록**이지 정본이 아니다 + 여기 적히는 것은 「저장 시점에 무엇을 고른 상태였나」이고, 계산을 되살릴 때 쓰는 정본은 + **프로젝트 스냅샷**이다. 둘이 어긋나면 **스냅샷이 이긴다.** + +⚠ `*_override` 는 기본이 `None` 이다 + 「프로젝트가 안 정했으면 `config` 정본을 쓴다」는 뜻이다. 기본값을 복사해 넣으면 나중에 + 정본이 바뀌어도 옛 프로젝트가 안 따라온다. 값을 넣는 것은 **설계자가 일부러 바꿨을 때만**이다. + +⚠ 반영률 기본은 100 이다 (PLAN 8-11 · 8-10 ★법대로) + 실무 관측 80/50/80 은 설계자가 비고란에 손으로 적은 값이지 법정값이 아니다. 기본값으로 + 넣지 않는다. + +작업본 3층 (CLAUDE.md 5장) + 조작은 캐시(sessionStorage)에 쌓이고 [저장]·[확정]에서 이 파일로 간다. 자동저장은 만들지 않는다. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterable + +from common_util.common_util_json import atomic_write_json + +SETTINGS_FILENAME = "project_settings.json" +SCHEMA_VERSION = 1 + +# 반영률 키 — 사면 계열과 짝이다. 값은 퍼센트이고 기본은 전부 100. +APPLICATION_RATIO_KEYS = ( + "fill_slope_compaction", # 성토면다짐 + "seed_spray_fill", # 초류종자살포(성토면) + "seed_spray_cut", # 초류종자살포(절토면) + "obstacle_removal", # 지장목제거 +) + +# 암 갈래 세트 — **개수를 코드에 박지 않는다**(PLAN 8-13). +# 울진 2 · 거창 5 · 오솔길 BOM 1 로 공사마다 다르다. 프로젝트가 하나를 고른다. +ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = { + "single": ("토사", "암"), + "uljin2": ("토사", "연암", "발파암"), + "geochang5": ("토사", "풍화암", "연암", "보통암", "경암"), +} +DEFAULT_ROCK_CLASS_SET = "geochang5" + +# 암 시공법 — 품셈이 공종을 가르는 기준. `None` 은 「아직 안 정함」이고 기본값이다. +ROCK_METHOD_RIPPING = "ripping" # 긁어내기 — 암절취 +ROCK_METHOD_BLASTING = "blasting" # 터뜨리기 — 발파암 +ROCK_METHODS = (ROCK_METHOD_RIPPING, ROCK_METHOD_BLASTING) + + +def rock_method(settings: dict[str, Any], rock_class: str) -> str | None: + """갈래 하나의 시공법. 안 정했으면 `None` — **기본값으로 때우지 않는다.**""" + value = (settings.get("rock_methods") or {}).get(rock_class) + return value if value in ROCK_METHODS else None + + +def default_settings() -> dict[str, Any]: + """빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다.""" + return { + "schema_version": SCHEMA_VERSION, + "quantity": { + "rock_class_set": DEFAULT_ROCK_CLASS_SET, + "rock_classes": list(ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]), + # 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 — + # 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다. + "rock_ratios_pct": {}, + # 갈래별 **시공법** — `{갈래이름: "ripping"|"blasting"}`. + # ⚠ 갈래 이름(연암·보통암…)만으로는 **긁어내는 암인지 터뜨리는 암인지** 알 수 없고, + # 품셈은 그 둘을 다른 공종으로 둔다(암절취 FP-09-04 / 발파암 FP-09-05). + # 기본은 **비워 둔다** — 찍으면 공종이 조용히 틀린다. 안 정하면 인계에서 + # 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리). + "rock_methods": {}, + "conversion_factors_override": None, + "haul_limits_m_override": None, + "application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS}, + # 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}` + # 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`. + # ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로 + # 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다. + # 표토제거 두께(m) — ⚠ **품셈이 정하는 값이 아니다.** 9-15 [주]② 가 + # 「T : 표토두께(m)」로 **공식의 입력 변수**로 두었다(2026-09-07 원문 확인). + # 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음). + "topsoil_thickness_m": None, + "material_supply": {}, + # 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) / + # `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다. + # ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야 + # 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다. + "concrete_placing_method": None, + "dataset_versions": {}, + }, + "estimation": { + # ⚠ 「연도」가 아니라 **판**을 가리킨다 — 조달청 제비율은 연중에도 개정된다 + # (현행판 2026-04-13). 「2026년」만으로는 어느 판인지 안 정해진다. + # 값은 `dataset_id` + `effective_date` + `sha256` 세 쪽. + "rate_dataset": None, + "price_slot_names": {}, + "dataset_versions": {}, + }, + } + + +def settings_path(project_root: str | Path) -> Path: + return Path(project_root) / SETTINGS_FILENAME + + +def load_settings(project_root: str | Path) -> dict[str, Any]: + """설정을 읽는다. 파일이 없거나 깨졌으면 기본값을 돌려준다(예외를 올리지 않는다). + + 읽기가 실패해도 화면은 서야 한다 — 설정은 계산을 **거드는** 값이지 없으면 못 도는 값이 아니다. + """ + path = settings_path(project_root) + if not path.exists(): + return default_settings() + try: + stored = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return default_settings() + if not isinstance(stored, dict): + return default_settings() + return _merge(default_settings(), stored) + + +def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]: + """저장분을 기본값 위에 얹는다. **새로 생긴 키가 빠지지 않게** 한 겹만 재귀한다.""" + merged = dict(base) + for key, value in stored.items(): + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = _merge(current, value) + else: + merged[key] = value + return merged + + +SECTIONS = ("quantity", "estimation") + + +def save_section( + project_root: str | Path, + section: str, + values: dict[str, Any], + *, + replace_keys: Iterable[str] = (), +) -> dict[str, Any]: + """한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**. + + 두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는 + 함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다. + + ⚠ `replace_keys` — **지울 수 있어야 하는 칸**은 병합이 아니라 통째로 갈아 끼운다. + 「고른 값을 안 정함으로 되돌리기」가 병합으로는 안 되기 때문이다(2026-09-07 화면에서 + 걸린 자리 — 시공법을 한 번 고르면 되돌릴 길이 없었다). + """ + if section not in SECTIONS: + raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})") + settings = load_settings(project_root) + merged = _merge(settings.get(section) or {}, values) + for key in replace_keys: + if key in values: + merged[key] = values[key] + settings[section] = merged + settings["schema_version"] = SCHEMA_VERSION + atomic_write_json(settings_path(project_root), settings) + return settings + + +def quantity_settings(project_root: str | Path) -> dict[str, Any]: + """B08 구획만 꺼낸다.""" + return load_settings(project_root).get("quantity") or {} + + +def rock_classes(settings: dict[str, Any]) -> list[str]: + """이 프로젝트의 암 갈래 목록. 세트 이름이 낯설면 저장된 목록을 그대로 쓴다.""" + stored = settings.get("rock_classes") + if isinstance(stored, list) and stored: + return [str(item) for item in stored] + name = str(settings.get("rock_class_set") or DEFAULT_ROCK_CLASS_SET) + return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET])) + + +#: 타설 방식 — 안 정했을 때 쓰는 값. **금액에 바로 걸리는 자리**라 화면이 잠정임을 띄운다. +CONCRETE_PLACING_METHODS = ("ready_mixed", "machine_mixed", "hand_mixed") +DEFAULT_CONCRETE_PLACING_METHOD = "ready_mixed" + + +def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]: + """(타설 방식, 기본값을 쓰고 있는가). + + ⚠ 둘째 값이 참이면 **사용자가 아직 안 정한 것**이다 — 화면이 「기본값 적용 중」을 띄워야 + 한다. 조용히 기본으로 돌면 사용자는 그것이 잠정인 줄도 모른다. + """ + stored = settings.get("concrete_placing_method") + if stored in CONCRETE_PLACING_METHODS: + return str(stored), False + return DEFAULT_CONCRETE_PLACING_METHOD, True + + +def application_ratio(settings: dict[str, Any], key: str) -> float: + """반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다.""" + raw = (settings.get("application_ratios_pct") or {}).get(key, 100) + try: + return float(raw) / 100.0 + except (TypeError, ValueError): + return 1.0 diff --git a/common_util/common_util_quantity_spread.py b/common_util/common_util_quantity_spread.py new file mode 100644 index 00000000..13be2b74 --- /dev/null +++ b/common_util/common_util_quantity_spread.py @@ -0,0 +1,47 @@ +"""산출 요약 — 값의 **크기가 말이 되나**를 한눈에 보이는 자리 (2026-09-07 조율 창 권고). + +왜 있나 + 서브 창이 씨앗뿜어붙이기를 **합계 68.8원**으로 세워 두고도 몰랐던 일이 있었다. + 값이 **있기는 하니** 어떤 시험도 안 잡는다. 자릿수가 어긋난 것은 사람이 훑어야 보이고, + 훑으려면 **최솟값·중앙값·최댓값이 표 옆에 떠 있어야** 한다. + +⚠ 이것은 검사가 아니라 **눈에 띄게 하는 장치**다 + 기준을 정해 놓고 걸러 내지 않는다 — 임도 물량은 ㎥·㎡·m·ton·개가 섞여 있어 「얼마 이하면 + 이상하다」를 한 벌로 못 정한다. **단위별로 나눠** 내고 판단은 사람에게 맡긴다. +""" + +from __future__ import annotations + +from statistics import median +from typing import Any, Iterable + + +def spread(values: Iterable[float]) -> dict[str, float] | None: + """최솟값·중앙값·최댓값. 값이 없으면 `None` — 0 으로 만들지 않는다.""" + numbers = [float(v) for v in values if isinstance(v, (int, float))] + if not numbers: + return None + return { + "min": min(numbers), + "median": float(median(numbers)), + "max": max(numbers), + "count": len(numbers), + } + + +def spread_by_unit( + rows: Iterable[dict[str, Any]], *, value_key: str +) -> dict[str, dict[str, float]]: + """단위별로 갈라 낸다. ㎥ 와 ton 을 한 통에 넣으면 최솟값이 뜻을 잃는다.""" + buckets: dict[str, list[float]] = {} + for row in rows: + value = row.get(value_key) + if not isinstance(value, (int, float)): + continue + buckets.setdefault(str(row.get("unit") or "?"), []).append(float(value)) + result: dict[str, dict[str, float]] = {} + for unit, numbers in buckets.items(): + found = spread(numbers) + if found: + result[unit] = found + return result diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 3dfd7032..16de9b3a 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -212,8 +212,59 @@ 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 + project_root: Path, + surface_params: dict[str, Any] | None = None, + route_range: tuple[float | None, float | None] | None = None, ) -> PlannedRoute | None: """설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다. @@ -225,14 +276,43 @@ def load_design_route( `surface_params`(확정 필터·방식·스무딩)를 주면 지표면이 덮지 못하는 구간을 잘라 내고, B05 격자 탐색이 계획노선을 바꾸지 않도록 정점 간격을 직결 문턱 아래로 좁힌다. 주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다. + + `route_range`(시작·종료 누가거리 m)를 주면 **서피스 트림보다 먼저** 그 구간만 남긴다 + (2026-09-04 사용자 지시). 순서가 바뀌면 사용자가 정한 시점이 서피스 트림에 밀린다. """ from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_initial_snapshot import design_route_csv_path from config.config_system import ( ROUTE_DIRECT_LINK_CELL_FACTOR, ROUTE_GRID_RES_M, ROUTE_PLANNED_DENSIFY_SAFETY, ) + # 체인이 남긴 CSV 정본이 있으면 그것이 설계 노선이다 — 좌표계 변환·트림·조밀화가 이미 + # 끝난 값이라 다시 하지 않는다(노선이 shapefile로 왔어도 여기서는 CSV 한 벌이다). + # 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로 + # 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 — + # surface_params 없음)은 여기를 타지 않는다. + if surface_params: + # 읽는 순서 — 수정본 → **초기 폴리라인** → 예상노선(점 묶음) → 초기값 스냅샷. + # 초기 폴리라인이 예상노선보다 앞선다: 예상노선은 점 묶음이라 그대로 이으면 + # 규칙 없는 선이 된다(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( + stored, + [(v.x, v.y) for v in stored.vertices], + crs_input=project_epsg_from_prj(project_root), + ) + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") planned = read_planned_route(route_file) if route_file else None if planned is None or len(planned.vertices) < 2: @@ -247,6 +327,10 @@ def load_design_route( transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True) points = [transformer.transform(x, y) for x, y in points] + # 사용자가 정한 범위가 먼저다 — 그 다음에 서피스 밖을 깎는다. + if route_range: + points = clip_route_by_chainage(points, route_range[0], route_range[1]) + if surface_params: from common_util.common_util_surface_sampler import build_surface_sampler @@ -260,7 +344,10 @@ def load_design_route( except (FileNotFoundError, KeyError, OSError) as exc: logger.warning("설계 노선: 지표면을 열지 못해 트림을 건너뜁니다 — %s", exc) else: + before = points points = trim_route_to_surface(points, sampler) + if len(points) < 2: + _log_trim_wipeout(before, sampler, target_crs) points = densify_route( points, ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, @@ -291,6 +378,87 @@ def replace_vertices( ) +def clip_route_by_chainage( + points: list[tuple[float, float]], + start_m: float | None, + end_m: float | None, +) -> list[tuple[float, float]]: + """사용자가 정한 누가거리 구간만 남긴다 (2026-09-04 사용자 지시). + + 계획노선 자료가 공사지 전체일 수 있어 **쓸 구간을 사용자가 정한다**(B02 등록 화면). + 경계는 정점 사이에 떨어질 수 있으므로 그 자리에 점을 하나 만들어 끼운다. + 둘 다 없으면 원본 그대로. 남는 구간이 2점 미만이면 원본을 돌려준다 — 범위가 자료를 + 벗어난 경우까지 여기서 노선을 지우면 원인을 못 찾는다(판정·안내는 화면·라우터 몫). + """ + if len(points) < 2 or (start_m is None and end_m is None): + return list(points) + low = max(0.0, float(start_m)) if start_m is not None else 0.0 + high = float(end_m) if end_m is not None else float("inf") + if high <= low: + return list(points) + + clipped: list[tuple[float, float]] = [] + travelled = 0.0 + for index in range(1, len(points)): + ax, ay = points[index - 1] + bx, by = points[index] + length = math.dist((ax, ay), (bx, by)) + if length <= 0.0: + continue + seg_start, seg_end = travelled, travelled + length + travelled = seg_end + if seg_end < low or seg_start > high: + continue + # 이 구간에서 남길 부분의 시작·끝 비율. + t0 = max(0.0, (low - seg_start) / length) + t1 = min(1.0, (high - seg_start) / length) + if t1 <= t0: + continue + first = (ax + (bx - ax) * t0, ay + (by - ay) * t0) + last = (ax + (bx - ax) * t1, ay + (by - ay) * t1) + if not clipped: + clipped.append(first) + clipped.append(last) + if len(clipped) < 2: + logger.warning( + "계획노선 범위 절단: 남는 구간이 없어 전 구간을 씁니다 — 범위 %s~%s m", + start_m, + end_m, + ) + return list(points) + return clipped + + +def _log_trim_wipeout(points: list[tuple[float, float]], sampler: Any, target_crs: str) -> None: + """트림이 노선을 통째로 지운 이유를 **수치로** 남긴다. + + 노선과 지표면의 좌표계가 어긋나면 트림 결과가 빈 목록이 되는데, 그때 로그가 + "겹치지 않습니다" 뿐이라 좌표계 문제인지 범위 문제인지 갈리지 않았다(2026-09-03 실사고: + 도엽 서피스가 노선 원본 좌표계로 만들어져 노선이 통째로 지워짐). 두 bbox 를 함께 찍어 + 한 줄로 판별되게 한다 — 겹침이 0이면 좌표계, 일부 겹치면 측량 범위 문제다. + """ + xs = [x for x, _ in points] + ys = [y for _, y in points] + grid_x = getattr(sampler, "x", None) + grid_y = getattr(sampler, "y", None) + surface = ( + f"[{float(min(grid_x)):.0f}~{float(max(grid_x)):.0f}, " + f"{float(min(grid_y)):.0f}~{float(max(grid_y)):.0f}]" + if grid_x is not None and grid_y is not None and len(grid_x) and len(grid_y) + else "미상" + ) + logger.warning( + "설계 노선: 지표면 트림 결과가 비었습니다 — 노선 bbox [%.0f~%.0f, %.0f~%.0f] / " + "지표면 bbox %s (작업 좌표계 %s). 두 범위가 전혀 안 겹치면 좌표계 불일치입니다.", + min(xs), + max(xs), + min(ys), + max(ys), + surface, + target_crs, + ) + + def trim_route_to_surface( points: list[tuple[float, float]], sampler: Any, 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..bb6e45b7 --- /dev/null +++ b/common_util/common_util_structure_lengths.py @@ -0,0 +1,103 @@ +"""구조물 정본에서 **시설별 연장(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 + +# 아직 셈하지 않는 타입 — 지금은 없다. +# +# 소단측구(`ditch_berm`)는 계획서 3-9 로 **소단이 실제로 서면서 빈 칸이 풀렸다** +# (2026-09-07). 이제 다른 구조물과 같은 길로 연장이 나온다. +PENDING_TYPE_IDS: frozenset[str] = frozenset() + + +def _merge(spans: list[tuple[float, float]]) -> list[tuple[float, float]]: + """겹치는 구간을 **합쳐** 실제 덮인 구간 목록을 낸다. + + 같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인 + 길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이 + 겹쳤다는 사실은 숨지 않는다. + + 합친 **구간 자체**를 돌려준다 — 길이만 내면 산출근거에 「어디부터 어디까지」를 못 적는다 + (2026-09-08 B08 창 요청). 길이는 부르는 쪽이 이 목록에서 더한다. + """ + merged: list[tuple[float, float]] = [] + 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 + merged.append((current_start, current_end)) + current_start, current_end = start, end + if current_start is not None: + merged.append((current_start, current_end)) + return merged + + +def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: + """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). + + 빼는 것 둘 — + · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. + · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 + 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). + (`PENDING_TYPE_IDS` 는 지금 비어 있다 — 소단측구가 3-9 로 풀렸다.) + + 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 + (`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] + merged = _merge(entries) + rows.append( + { + "type_id": type_id, + "group": types[type_id].group, + "name": types[type_id].name, + "count": count, + # 겹침을 지운 실제 연장 — 수량서에 쓸 값. + "length_m": round(sum(end - start for start, end in merged), 2), + # 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻. + "raw_length_m": round(sum(end - start for start, end in entries), 2), + # 겹침을 지운 **구간 목록**(누가거리 m) — 산출근거에 「어디부터 어디까지」를 + # 적는 자리다. 길이는 이 목록의 합과 같다(2026-09-08 B08 창 요청). + "spans": [ + {"start_m": round(start, 2), "end_m": round(end, 2)} for start, end in merged + ], + } + ) + 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/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index a1d4cacd..258fd6da 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -168,7 +168,16 @@ async def fail_stage( async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Dict[str, Any]: - """프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다.""" + """프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다. + + 프로젝트 이름도 함께 싣는다 (2026-09-04 사용자 지시) — B03~B08 좌측 제목 줄 오른쪽에 + 이름을 붙이는데, 화면이 들고 있는 것은 프로젝트 id 뿐이라 여기서 내려 준다. 새로고침· + 주소 직접 입력으로 들어와도 같은 값이 따라온다. + """ + await cursor.execute("SELECT name FROM projects WHERE id = %s", (project_id,)) + name_row = await cursor.fetchone() + project_name = name_row["name"] if name_row else None + await cursor.execute( """ SELECT stage_no, stage_key, state, progress_percent, params, message, @@ -183,7 +192,12 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di rows = await cursor.fetchall() if not rows: - return {"project_id": project_id, "current_stage": 0, "stages": []} + return { + "project_id": project_id, + "project_name": project_name, + "current_stage": 0, + "stages": [], + } stages_list = [] for r in rows: @@ -221,6 +235,7 @@ async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Di return { "project_id": project_id, + "project_name": project_name, "current_stage": current_stage, "stages": stages_list, } @@ -245,7 +260,41 @@ async def is_analysis_running(cursor: aiomysql.DictCursor, project_id: str) -> b if not row: return False state = row["state"] if isinstance(row, dict) else row[0] - return str(state) == "IN_PROGRESS" + if str(state) != "IN_PROGRESS": + return False + # 자동 확정이 보류되면 1단계는 사용자가 B04에서 모델을 고를 때까지 IN_PROGRESS 로 + # 남는다 — 계산은 이미 끝난 상태다. 그 사이에도 새 자료는 받아야 한다 + # (2026-09-04 사용자 지시 — 같은 프로젝트로 전처리를 되풀이 시험). + return not await _surface_run_settled(cursor, project_id) + + +# 계산이 끝나 사용자의 다음 조작을 기다리는 진행 단계 — 도는 중이 아니다. +_SETTLED_SURFACE_STAGES = frozenset({"awaiting_confirmation", "completed", "failed"}) + + +async def _surface_run_settled(cursor: aiomysql.DictCursor, project_id: str) -> bool: + """전처리 진행 파일이 「끝났음」으로 적혀 있는가. 파일이 없으면 판단하지 않는다.""" + from pathlib import Path + + from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress + from common_util.common_util_storage import resolve_stored_project_path + + await cursor.execute( + "SELECT storage_path FROM projects WHERE id = %s AND deleted_at IS NULL", + (project_id,), + ) + row = await cursor.fetchone() + if not row: + return False + storage_path = row["storage_path"] if isinstance(row, dict) else row[0] + if not storage_path: + return False + try: + progress = read_surface_progress(Path(resolve_stored_project_path(str(storage_path)))) + except (OSError, ValueError): + return False + stage = str((progress or {}).get("current_stage") or "") + return stage in _SETTLED_SURFACE_STAGES async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_id: str) -> None: 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 794fa3a8..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 = ( @@ -108,687 +115,11 @@ SEND_ANALYSIS_COMPLETION_EMAIL = ( AUTO_DESIGN_CHAIN_ENABLED = os.getenv("AUTO_DESIGN_CHAIN_ENABLED", "True").lower() == "true" # ───────────────────────────────────────────────────────────────────────── -# 5. 지형 분석 알고리즘 파라미터 -# ───────────────────────────────────────────────────────────────────────── -# Trimesh 메쉬 생성 -MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 미터 단위 -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")) -SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) - -# CSF (Cloth Simulation Filter) 지면 분류 파라미터 -SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5")) -SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1")) -SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65")) -SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5")) -# 낙하 후 스프링 정착에 쓰는 여유 반복 수 (실제 낙하 구간은 현장 기복에서 계산한다) -SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150")) -# 낙하+정착 반복 상한 — 기복 1500m(0.3185m/회)까지 커버한다 -SURFACE_CSF_MAX_ITERATIONS = int(os.getenv("SURFACE_CSF_MAX_ITERATIONS", "5000")) -SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true" -SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float( - os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8") -) - -# PMF (Progressive Morphological Filter) 지면 분류 파라미터 -SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0")) -SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40")) -SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3")) -SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0")) -SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5")) - -# RANSAC (Local plane fitting) 지면 분류 파라미터 -SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3")) -SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3")) -SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100")) -SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0")) -SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) - -# LAS 자체 지면분류(class 2) 필터 — 이 비율 이상 분류돼 있을 때만 필터로 제공한다. -# 미분류 LAS는 classification이 전부 0이라 마스크가 비어 버린다. -SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( - os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002") -) -# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. -SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) - -# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). -# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 -# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). -SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0")) - -# ───────────────────────────────────────────────────────────────────────── -# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) -# ───────────────────────────────────────────────────────────────────────── -# 고를 수 있는 지면 필터 전체 — 관리자가 B04 드롭다운에서 요청할 때 쓰인다. -# 자동 전처리가 미리 만드는 조합이 아니다(그건 SURFACE_AUTO_* 가 정한다). -SURFACE_MODEL_SOURCE_FILTERS = tuple( - os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "classification,grid_min_z,csf,pmf").split(",") -) -# 자동 전처리가 만드는 조합 — 기본 필터 1종 × 아래 표현. 나머지는 관리자 요청 시 만든다. -SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(",")) -# 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값. -SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "csf") -# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) -SURFACE_MODEL_PRECOMPUTE = tuple( - os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") -) -SURFACE_MODEL_SMOOTHING_METHODS = tuple( - os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") -) -SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0")) - -# footprint(외곽) 산출 -SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) -SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) -SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) -SURFACE_KEEP_LARGEST_FOOTPRINT = ( - os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" -) -SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) -SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000")) - -# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F) -SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000")) -SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) -SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) -SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) -SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( - os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") -) -SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( - os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") -) -SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) -SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) -SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) - -# 스무딩 파라미터 -SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) -SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0")) -SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float( - os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5") -) -SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int( - os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10") -) -SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5")) -SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) - -# 등고선 파라미터 -SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) -SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) - -# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정) -# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값). -SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0")) -# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값). -SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0")) -# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다 -# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py. -SHEET_SURFACE_METHODS = [ - method.strip() - for method in os.getenv( - "SHEET_SURFACE_METHODS", - "tin_sheet,tin,biharmonic,anudem,multires,laplace", - ).split(",") - if method.strip() -] -# 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). -SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") -# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 -# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서 -# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정). -SHEET_SURFACE_AUTO_METHODS = [ - method.strip() - for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",") - if method.strip() -] - -# 일반 사용자 WF1 자동 확정 기본값 -SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") -SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") -SURFACE_CONFIRM_DEFAULT_SMOOTH = ( - os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" -) - - -def build_surface_model_config() -> dict: - """지표면 모델 파이프라인이 사용하는 config dict를 조립한다.""" - return { - "source_filters": list(SURFACE_MODEL_SOURCE_FILTERS), - "precompute": list(SURFACE_MODEL_PRECOMPUTE), - "smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS), - "sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS, - "footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M, - "footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M, - "boundary_inset_meters": SURFACE_BOUNDARY_INSET_M, - "keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT, - "tile_size_meters": SURFACE_TILE_SIZE_M, - "max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES, - "tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS, - "dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M, - "nurbs_degree": SURFACE_NURBS_DEGREE, - "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, - "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, - "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, - "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, - "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, - "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, - "smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M, - "smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, - "smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, - "smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS, - "smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA, - "smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU, - "contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M, - "contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M, - } - - -# ───────────────────────────────────────────────────────────────────────── -# 5-3. 경로 설계 파라미터 (B05 WF2) -# ───────────────────────────────────────────────────────────────────────── -# 비용 함수 가중치 -ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0")) -ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0")) -ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5")) -ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5")) -ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0")) -ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0")) - -# 격자·경사·비용 제약 -ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14")) -ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18")) -ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0")) -ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0")) -ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") -ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) -ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) -# 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선 -# 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변). -ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0")) -# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. -# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. -ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) -# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 -# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). -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} - -# 대안(정속경사) 파라미터 -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")) -ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005")) - -# 지형 skeleton (능선/계곡) 파라미터 -SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500")) -SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int( - os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000") -) -SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500")) -SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( - os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000") -) -SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) - - -# ───────────────────────────────────────────────────────────────────────── -# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계) -# -# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링) -# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가 -# 유일한 판정 기준이며, 그 경계가 곧 능선이다. -# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시). -# ───────────────────────────────────────────────────────────────────────── -# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. -DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) -# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로 -# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다. -# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이 -# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). -DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0")) -# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). -DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) -# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). -DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) -# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. -DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) -# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). -DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) -# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 -# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. -DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) -# 해석용 **도로 굽기 폭**(m) — 설계 도로폭이 아니다. -# -# 설계 도로폭은 등급별로 따로 있다(B05 `/sections/road-widths`: 간선 3.0 / 지선 3.0 / 작업로 2.5m). -# 여기 값은 노선을 격자에 구울 때만 쓰는 해석 파라미터다. 1m 격자에서 도로를 1셀 선으로 구우면 -# D8 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리므로, 3셀(=3m) 이상 두께가 필요하다. -# 그래서 설계폭과 연동하지 않고 4m로 둔다(2026-08-01 사용자 확인). 값을 바꾸면 재분석해야 한다. -DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) -# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다. -DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")) -# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다. -DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) -# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. -DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) -# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망 -# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다. -DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0")) -# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. -DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) -# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. -DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0")) -# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. -DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0")) -# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). -DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) -# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. -DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0")) -# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). -DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) -# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_PreProcess/drainage/ 아래에 놓인다. -DRAINAGE_CACHE_DIRNAME = "drainage" -DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" -# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — -# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). -DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" -# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다. -DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson" - -# ── 관 매설 지점 편집분 ── -# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다. -# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다. -DRAINAGE_EDITS_DIRNAME = "edits" -DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" - -# ── B05용 평균 흐름 화살표 ── -# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. -DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) -# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. -DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) -# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). -DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) -# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. -DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) -# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. -# 파일명 규칙은 B05_Profile_Engine_Watershed_Export.STAGES가 유일한 정의처다. - -# ── 확률강우량 (map.wamis.go.kr 등우선도 — 제주 전용) ── -# 등우선 서버 원본은 제주도만 커버한다(2026-08-13 실측) — 제주 공사지에서만 쓴다. -# 근거·검증: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 5절, PLAN.md A. -WAMIS_CONTOUR_CACHE_DIR = PROJECT_ROOT / "resources" / "data_rainfall_idf_cache" -# 프로젝트별 결과 파일명. drainage/ 아래에 놓인다. -DRAINAGE_RAINFALL_FILENAME = "rainfall_table.json" - -# ── 확률강우량 (관측소 방식 — 본토, 2026-08-13 사용자 확정 로직) ── -# 최근접 관측소 + 이 반경 안 관측소를 모두 받아 100년/24hr 최대 지점을 선정한다. -WAMIS_STATION_RADIUS_M = float(os.getenv("WAMIS_STATION_RADIUS_M", "10000")) -# 관측소 목록·확률강우량 xlsx의 프로젝트별 보존 폴더명. drainage/ 아래에 놓인다 -# (원천이 갱신되는 자료라 프로젝트 시점 스냅샷 — 전역 공통 캐시 아님). -DRAINAGE_RAINFALL_STATION_DIRNAME = "rainfall_stations" -# 설계빈도 강우강도 산출 방식: "mononobe"(기본, 실무 수리계산서 방식) | "general" -DRAINAGE_RAINFALL_IDF_METHOD = os.getenv("DRAINAGE_RAINFALL_IDF_METHOD", "mononobe") - -# ── 배수 유효직경 계산 계수 (2026-08-05 사용자 확정, 위 규정 조사 문서 6절) ── -# 합리식 유출계수 C. 산악지 준용 0.8 — 필요 시 사용자가 이 값을 고친다. -DRAINAGE_RUNOFF_COEFFICIENT = float(os.getenv("DRAINAGE_RUNOFF_COEFFICIENT", "0.8")) -# 설계유량 배수. 시행규칙 별표2 (가): 최대홍수유출량의 2.0배 이상. -DRAINAGE_DESIGN_FLOW_FACTOR = float(os.getenv("DRAINAGE_DESIGN_FLOW_FACTOR", "2.0")) -# 설계빈도(년). 별표2 (가) 1호: 100년빈도 확률강우량. -DRAINAGE_DESIGN_RETURN_PERIOD_YR = int(os.getenv("DRAINAGE_DESIGN_RETURN_PERIOD_YR", "100")) -# 홍수도달시간 하한(분). 국도건설공사 설계실무요령·도로배수지침 "강우지속기간 5분 원칙". -DRAINAGE_TC_MIN_MINUTES = float(os.getenv("DRAINAGE_TC_MIN_MINUTES", "5.0")) -# 관 경사(도). 시공 중 수시 변경되므로 지형 산출 대신 실무 대푯값 10도로 고정(사용자 제시). -DRAINAGE_PIPE_SLOPE_DEG = float(os.getenv("DRAINAGE_PIPE_SLOPE_DEG", "10.0")) -# Manning 조도계수(파형강관). -DRAINAGE_MANNING_N = float(os.getenv("DRAINAGE_MANNING_N", "0.024")) -# 관내 유속 허용범위(m/s). 도로배수지침 0.8~3.0 — 상한 클램프가 안전측(관이 커진다). -DRAINAGE_VELOCITY_MIN_MS = float(os.getenv("DRAINAGE_VELOCITY_MIN_MS", "0.8")) -DRAINAGE_VELOCITY_MAX_MS = float(os.getenv("DRAINAGE_VELOCITY_MAX_MS", "3.0")) -# 원형관 통수단면 비율. 도로설계요령: 관 단면의 70%만 통수 고려. -DRAINAGE_FLOW_AREA_RATIO = float(os.getenv("DRAINAGE_FLOW_AREA_RATIO", "0.7")) -# 세월교 검토 문턱(mm). 유효직경이 관 최대 규격(파형강관 D2000)을 넘으면 관이 아니라 -# 세월교·물넘이·교량 대상이다 — 「임도설치 및 관리 등에 관한 규정」 제12조: 계류 횡단 -# 구간은 배수구 막힘 우려가 없는 물넘이 포장(세월교) 또는 교량으로 설계(2026-08-05 조사). -DRAINAGE_BRIDGE_THRESHOLD_MM = float(os.getenv("DRAINAGE_BRIDGE_THRESHOLD_MM", "2000")) -# BOX암거 전환 문턱(mm). 교본 "수리계산 Ø1,500㎜ 이상 유역·협곡·횡단경사 40% 이내"에서 -# 유량 조건만 취한 값이다 — 횡단경사·협곡 판정은 지형 계산이 필요해 화면이 "현장 확인"으로 -# 안내한다 (2026-08-17 사용자 확정: 추천은 유량 근거만). -DRAINAGE_BOX_THRESHOLD_MM = float(os.getenv("DRAINAGE_BOX_THRESHOLD_MM", "1500")) -# 추천 관경 규격(mm) — B05 레지스트리 `pipe_diameter_mm` 선택지와 **같아야 한다**. -# 폼에서 고를 수 없는 값을 추천하면 사용자가 그대로 확정하지 못한다. -DRAINAGE_RECOMMEND_DIAMETERS_MM = (800, 1000, 1200, 1500) - -# ── 물넘이포장·세월교 개략 단면 (2026-08-17 실무문서 역산 확인) ── -# 물넘이는 노면 개수로다. 조도계수 0.017은 KDS 표 2.7-1 콘크리트 수로 "보통"값이자 -# 실무 물넘이 관측치와 일치하고, 경사 10%는 실무 수리계산서 역산값이다 — 울진1공구 -# 물넘이 시트(B=20m·h=0.27m·n=0.017 → V=7.636 m/s)와 같은 공사지 관 시트(Ø1000·240° -# 유효단면·n=0.025 → V=5.694 m/s)가 둘 다 S=0.10에서 검산이 맞는다 -# (original/실무문서/_숨김탭분석.md §6-1·§6-2). -FORD_MANNING_N = float(os.getenv("FORD_MANNING_N", "0.017")) -FORD_SLOPE = float(os.getenv("FORD_SLOPE", "0.10")) - -# ── B05 구조물 옵션 (드롭다운 목록·기본값, 2026-08-05 사용자 확정) ── -# 프론트는 이 값을 /api 설정 응답 또는 빌드타임 복사로 받아 쓴다. 수정은 여기서만 한다. -STRUCTURE_TYPES = ("배관", "기성막이", "대피로", "기타") -STRUCTURE_DEFAULT_TYPE = "배관" -PIPE_DIAMETERS_MM = { - "이중벽관": (150, 200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), - "삼중벽관": (200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), - "파형강관": ( - 150, - 200, - 250, - 300, - 350, - 400, - 450, - 500, - 600, - 700, - 800, - 900, - 1000, - 1200, - 1350, - 1500, - 1650, - 1800, - 2000, - ), -} -PIPE_DEFAULT_TYPE = "파형강관" -# 자동 지정 기본 관경. 별표2 (나) 예외 하한 800mm(사용자 결정 — 계산값이 더 크면 바로 위 규격). -PIPE_DEFAULT_DIAMETER_MM = int(os.getenv("PIPE_DEFAULT_DIAMETER_MM", "800")) -ESCAPE_ROUTE_WIDTHS_M = (1.5, 2.0, 2.5, 3.0) -ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0 -STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물" - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4. 종횡단 생성 파라미터 (B06 WF3) -# ───────────────────────────────────────────────────────────────────────── -SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) -# 초기 파이프라인(파일입력→B06) 샘플 반폭 — 표시 반폭보다 넉넉히 뽑아 두면 사용자가 -# 표시 반폭을 이 안에서 바꿀 때 재계산이 필요 없다(2026-08-06 사용자 확정, 기준 20m). -# 표시 반폭이 이 값을 넘을 때만 B05부터 재생성한다. 향후 사용자 평균 설정 보고 조정. -SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "20.0")) -SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) -SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) -SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) -SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" -FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-1. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정) -# -# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값. -# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장). -# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %. -# -# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과 -# 절·성토 단면적 엔진(B06_Section_Engine_Design)이 모두 여기를 읽으며, -# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다. -# ───────────────────────────────────────────────────────────────────────── -STANDARD_CROSS_SECTION = { - "soil": { - "road_width_m": 3.0, # 노폭(차도) - "shoulder_left_m": 0.5, # 노견(좌) - "shoulder_right_m": 0.5, # 노견(우) - # 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm. - "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, - "cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향) - "fill_slope_ratio": 1.2, # 성토 1:1.2 - "cut_slope_ratio": 1.0, # 절토 1:1.0 - }, - "rock": { # 리핑암·발파암 공유 - "road_width_m": 3.0, - "shoulder_left_m": 0.5, - "shoulder_right_m": 0.5, - # 일반 측구 = 690/300/300mm. - "ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, - # L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택). - "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, - "cross_slope_pct": {"min": 3.0, "max": 3.0}, - "fill_slope_ratio": 1.2, - "cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상) - }, - "paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름 - "road_width_m": 3.0, - "shoulder_left_m": 0.5, - "shoulder_right_m": 0.5, - "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, - "cross_slope_pct": {"min": 1.5, "max": 2.0}, - "fill_slope_ratio": 1.2, - "cut_slope_ratio": 1.0, - # 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능). - "pavement_thickness_m": 0.2, - }, -} -# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m). -STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5 -STANDARD_ROCK_BOUNDARY_STEP_M = 0.1 - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3) -# -# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지). -# 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종 -# 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분). -# ───────────────────────────────────────────────────────────────────────── -_SCS_SOIL = STANDARD_CROSS_SECTION["soil"] -# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능. -SECTION_ROADBED_WIDTH_M = float( - os.getenv( - "SECTION_ROADBED_WIDTH_M", - str( - _SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"] - ), - ) -) -SECTION_CARRIAGEWAY_WIDTH_M = float( - os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"])) -) -# 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정. -SECTION_FILL_SLOPE_RATIO = float( - os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"])) -) -# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다. -SECTION_GROUND_TYPE_PRESET = { - "soil": "soil", - "ripping_rock": "rock", - "blasting_rock": "rock", -} -# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. -SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") -SECTION_DITCH_SIDES = ("left", "right") -# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지). -SECTION_DITCH_TYPES = ("standard", "l_type") - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-3. 토량환산계수 (B06 유토곡선) -# -# loose(L) = 흐트러진 상태 토량 / 자연상태 토량 (팽창률) -# compacted(C) = 다져진 상태 토량 / 자연상태 토량 (다짐률) -# -# 유토곡선은 운반계획 도면이므로 **다짐상태 기준**으로 계산한다. -# "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, -# 내역서에 적용하는 수량은 자연상태로 한다." -# (2021년도 국도건설공사 설계실무 요령 / 2016 건설공사 표준품셈 계열) -# -# 아래 값은 표준품셈 토량환산계수표의 암종별 범위를 B06 지반유형 3종에 대응시킨 -# 제안값이며 임도 전용 고시 수치가 아니다. -# 토사 ← 풍화토(L 1.10~1.25 / C 0.80~0.90) ~ 점토(L 1.20~1.35 / C 0.75~0.90) -# 리핑암 ← 풍화암(L 1.30~1.35 / C 1.00~1.15) ~ 연암(L 1.30~1.50 / C 1.00~1.30) 하단 -# 발파암 ← 보통암(L 1.55~1.70 / C 1.20~1.40) 중앙 -# 표준품셈도 "토질 시험하여 적용하는 것을 원칙으로 하되 소량인 경우 환산계수표에 -# 따를 수 있다"고 하므로 현장별 조정이 전제다. 값을 바꾸려면 여기만 고치면 된다. -# -# 이 상수가 **토량환산계수의 유일한 정의처**다(타 파일 중복 정의 금지). -# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 8절 -# ───────────────────────────────────────────────────────────────────────── -EARTHWORK_CONVERSION_FACTORS = { - "soil": {"loose": 1.25, "compacted": 0.90}, - "ripping_rock": {"loose": 1.35, "compacted": 1.15}, - "blasting_rock": {"loose": 1.60, "compacted": 1.30}, -} - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획) -# -# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` -# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. -# -# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭 -# -# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을 -# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, -# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. -# 현장·발주처 기준에 따라 달라질 수 있으므로 -# **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다. -# -# key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프. -# 화면 표기 문구는 하드코딩 금지 규칙에 따라 ui_locales에서 따로 관리한다. -# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md -# ───────────────────────────────────────────────────────────────────────── -EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( - ("free_haul", 20.0), - ("dozer", 70.0), - ("dump_truck", None), -) - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-2. 자연방토 판정 경사 (B06 유토곡선) -# -# 자연방토 = 남는 흙을 성토사면 아래로 흘려보내 **운반비를 세지 않는** 처리. -# 판정 기준은 **성토측 자연 지반의 경사**다(2026-08-02 사용자 확정). 지반이 이보다 가파르면 -# 부어 놓은 흙이 쌓이지 않고 스스로 흘러내리므로 따로 운반하지 않는다. -# -# 값은 rise/run(무차원). 1/1.5 = 0.667 ≈ 33.7°로, 토사 안식각 상한이자 표준 성토 경사 1:1.5와 -# 같은 자리다. 현장·발주처 기준에 따라 달라질 수 있으므로 **이 상수가 유일한 정의처**다. -# ───────────────────────────────────────────────────────────────────────── -NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 - - -# ───────────────────────────────────────────────────────────────────────── -# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) -# -# 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준. -# 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어 -# 등급은 grade_to_design_speed로 간접 매핑한다. -# 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 -# 서로 혼용하지 않는다. -# ───────────────────────────────────────────────────────────────────────── -FOREST_ROAD_PROFILE_CRITERIA = { - # 설계속도(km/h)별 법정 기준 - "design_speed": { - 40: { - "max_grade_pct": {"normal": 7.0, "special": 10.0}, - "max_reverse_grade_pct": 5.0, - "min_vertical_radius_m": 450.0, - "min_curve_length_m": 40.0, - }, - 30: { - "max_grade_pct": {"normal": 8.0, "special": 12.0}, - "max_reverse_grade_pct": 5.0, - "min_vertical_radius_m": 250.0, - "min_curve_length_m": 30.0, - }, - 20: { - "max_grade_pct": {"normal": 9.0, "special": 14.0}, - "max_reverse_grade_pct": 5.0, - "min_vertical_radius_m": 100.0, - "min_curve_length_m": 20.0, - }, - }, - # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 - # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 - # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 - # 설계속도가 오면 그 값이 우선한다(design_speed_kph). - # trunk = 간선임도 / fire = 산불진화임도 / work = 작업임도 - # branch(지선)는 현행 규칙에서 폐지 — 기존 데이터 호환용으로만 남긴다. - "grade_to_design_speed": {"trunk": 20, "fire": 20, "work": 20, "branch": 20}, - # 화면에서 고를 수 있는 설계속도 — 작업임도는 20만 허용된다(별표2 Ⅰ.3). - "selectable_design_speeds": {"trunk": (20, 30, 40), "fire": (20, 30, 40), "work": (20,)}, - # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 - "paved_exception_grade_pct": 18.0, - # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 - "vertical_curve_skip_delta_pct": 5.0, - # 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값) - "min_tangent_length_m": 20.0, - # 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다) - "balance_segment_length_m": None, - # 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면 - # 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다. - # V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다. - "main_direction_monotone_ratio": 0.5, -} -GRADE_TERRAIN_TYPES = ("normal", "special") -# 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용) -GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") - - -# ───────────────────────────────────────────────────────────────────────── -# 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2) -# -# 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을 -# "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로 -# 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다. -# -# 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로 -# 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다. -# ───────────────────────────────────────────────────────────────────────── -FOREST_ROAD_PROFILE_ALIGNMENT = { - # 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%) - # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 - # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. - "balance_tolerance_percent": 10.0, - # 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치). - # R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다). - # L = R × |기울기 대수차 A(비율)| - # 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로 - # 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다 - # (예: 20.0 → R=400m, A=10%일 때 L=40m). - "curve_radius_ratio": 0.40, - # **기본 종단곡선 길이 L(m) — 모든 변화점의 1차 기준값.** - # 임도 계획선은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면 - # L = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다 - # (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다. - # 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다. - # 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만 - # 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시). - # 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다. - "default_curve_length_m": 15.0, - # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. - # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. - "curve_tangent_max_ratio": 0.45, - # 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지. - # 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가 - # 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고 - # 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다). - "curve_skip_legal_exception": False, - # 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²). - # 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다. - "pvi_penalty_m2": 25.0, - # 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지) - "min_segment_stations": 2, - # 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량. - "edit_step_m": 0.1, - # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) - "grade_violation_policy": "warn", -} - +# 5장(지형·설계 파라미터)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +# 여기서 그대로 다시 내보내므로 `from config.config_system import …` 호출부는 불변이다. +from config.config_system_design import * # noqa: E402,F401,F403 +from config.config_system_design import _SCS_SOIL as _SCS_SOIL # noqa: E402 +from config.config_system_terrain import * # noqa: E402,F401,F403 # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 경로 @@ -831,9 +162,33 @@ DRAWING_SHEET = "A1" # 용지 규격 (840x594 mm 도각 템플릿) # 축척분모를 그대로 못 쓴다 — 종이 1 mm가 받는 토량(50 ㎥)으로 적는다. # 유역도 : 평면 1/6,000 (가로·세로 같은 배율) # A1 유효 작도영역 693x468 mm를 넘으면 늘리지 않고 경고만 남긴다(척도 보존). -DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (실거리 1 m = 0.5 mm) +DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (자동 선정 실패 시 폴백) +# 토적도 가로 축척 후보 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다 +# (2026-09-03 사용자 결정: 길이별 자동 축척). 도면 관행 축척만 둔다. +DRAWING_SCALE_MASSHAUL_H_CANDIDATES = ( + 500, + 600, + 1000, + 1200, + 1500, + 2000, + 2500, + 3000, + 4000, + 5000, + 6000, +) +# A1 작도영역 가로(770 mm)에서 여백 2%와 좌측 행 이름칸·축 여유를 뺀 실제 가용 폭. +DRAWING_MASSHAUL_USABLE_WIDTH_MM = 700.0 DRAWING_SCALE_MASSHAUL_V_M3_MM = 50.0 # 유토곡선 세로 — 종이 1 mm 당 토량(㎥) DRAWING_SCALE_BASIN = 6000 # 유역도 평면 축척 분모 (실거리 1 m = 1/6 mm) +# 계획평면도·용지도 평면 축척 분모 — 지식DB 「설계제원_총괄」 측량·도면 기준 1/1,200. +# 횡단면도와 같은 원칙: 축척은 줄이지 않고, 한 장에 안 들어가면 **장을 나눈다**. +DRAWING_SCALE_PLAN = 1200 +# 표준 횡단면도 — 상세도라 지식DB에 지정 축척이 없다. 본 그림 1/50, 측구 부분확대도 +# 1/10 (2026-09-04). 노폭 4 m 기준 본 그림이 A1 작도영역에 여유 있게 든다. +DRAWING_SCALE_CROSS_STANDARD = 50 +DRAWING_SCALE_DITCH_DETAIL = 10 # 시스템 리소스 로그 (루트 log 폴더에 단일 파일, 1개월 보관) LOG_BASE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log") @@ -883,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 new file mode 100644 index 00000000..6811f5c9 --- /dev/null +++ b/config/config_system_design.py @@ -0,0 +1,572 @@ +"""경로·종횡단·계획선 설계 파라미터 (5-3장 ~ 5-6장). + +`config_system.py` 가 700줄을 넘어 떼어냈다(2026-09-04). 값·이름은 그대로이고, +`config_system` 이 다시 내보내므로 기존 `from config.config_system import …` 는 불변이다. +""" + +import os +from pathlib import Path + +# 지형 파라미터를 참조하는 값이 있어 함께 가져온다. +from config.config_system_terrain import * # noqa: F403 + +# 저장소 뿌리 — 본체(`config_system`)를 되부르면 순환이라 여기서 같은 식으로 잡는다. +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# ───────────────────────────────────────────────────────────────────────── +# 5-3. 경로 설계 파라미터 (B05 WF2) +# ───────────────────────────────────────────────────────────────────────── +# 비용 함수 가중치 +ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0")) +ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0")) +ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5")) +ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5")) +ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0")) +ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0")) + +# 격자·경사·비용 제약 +ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14")) +ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18")) +ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0")) +ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0")) +ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") +ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) +ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) +# 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선 +# 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변). +ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0")) +# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. +# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. +ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) +# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 +# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). +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} + +# 대안(정속경사) 파라미터 +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")) +ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005")) + +# 지형 skeleton (능선/계곡) 파라미터 +SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500")) +SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int( + os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000") +) +SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500")) +SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( + os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000") +) +SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) + + +# ───────────────────────────────────────────────────────────────────────── +# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계) +# +# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링) +# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가 +# 유일한 판정 기준이며, 그 경계가 곧 능선이다. +# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시). +# ───────────────────────────────────────────────────────────────────────── +# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. +DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) +# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로 +# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다. +# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이 +# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). +DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0")) +# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). +DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) +# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) +# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. +DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) +# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) +# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 +# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. +DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) +# 해석용 **도로 굽기 폭**(m) — 설계 도로폭이 아니다. +# +# 설계 도로폭은 등급별로 따로 있다(B05 `/sections/road-widths`: 간선 3.0 / 지선 3.0 / 작업로 2.5m). +# 여기 값은 노선을 격자에 구울 때만 쓰는 해석 파라미터다. 1m 격자에서 도로를 1셀 선으로 구우면 +# D8 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리므로, 3셀(=3m) 이상 두께가 필요하다. +# 그래서 설계폭과 연동하지 않고 4m로 둔다(2026-08-01 사용자 확인). 값을 바꾸면 재분석해야 한다. +DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) +# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다. +DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")) +# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다. +DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) +# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. +DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) +# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망 +# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다. +DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0")) +# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. +DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) +# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. +DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0")) +# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. +DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0")) +# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). +DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) +# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. +# 격자가 1m(DRAINAGE_GRID_SIZE_M)라 경계는 1m 계단이다 — 허용오차를 격자 한 칸에 맞추면 +# 형상 손실이 격자 오차 안에 들어간다. 2.0m는 유역별로 따로 단순화하는 탓에 공유 경계가 +# 크게 어긋났다(2026-09-03 실측, 용화: 중첩 1,043㎡·틈 1,184㎡ → 1.0m에서 243㎡·369㎡, +# 면적오차 1.86%→1.01%, 좌표점 622→1,749). 0.5m 이하는 계단 점을 못 지워 좌표점만 6배로 +# 튄다(10,215). +DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "1.0")) +# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). +DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) +# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_PreProcess/drainage/ 아래에 놓인다. +DRAINAGE_CACHE_DIRNAME = "drainage" +DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" +# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — +# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). +DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" +# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다. +DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson" + +# ── 관 매설 지점 편집분 ── +# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다. +# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다. +DRAINAGE_EDITS_DIRNAME = "edits" +DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" + +# ── B05용 평균 흐름 화살표 ── +# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. +DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) +# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. +DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) +# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). +DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) +# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. +DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) +# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. +# 파일명 규칙은 B05_Profile_Engine_Watershed_Export.STAGES가 유일한 정의처다. + +# ── 확률강우량 (map.wamis.go.kr 등우선도 — 제주 전용) ── +# 등우선 서버 원본은 제주도만 커버한다(2026-08-13 실측) — 제주 공사지에서만 쓴다. +# 근거·검증: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 5절, PLAN.md A. +WAMIS_CONTOUR_CACHE_DIR = PROJECT_ROOT / "resources" / "data_rainfall_idf_cache" +# 프로젝트별 결과 파일명. drainage/ 아래에 놓인다. +DRAINAGE_RAINFALL_FILENAME = "rainfall_table.json" + +# ── 확률강우량 (관측소 방식 — 본토, 2026-08-13 사용자 확정 로직) ── +# 최근접 관측소 + 이 반경 안 관측소를 모두 받아 100년/24hr 최대 지점을 선정한다. +WAMIS_STATION_RADIUS_M = float(os.getenv("WAMIS_STATION_RADIUS_M", "10000")) +# 관측소 목록·확률강우량 xlsx의 프로젝트별 보존 폴더명. drainage/ 아래에 놓인다 +# (원천이 갱신되는 자료라 프로젝트 시점 스냅샷 — 전역 공통 캐시 아님). +DRAINAGE_RAINFALL_STATION_DIRNAME = "rainfall_stations" +# 설계빈도 강우강도 산출 방식: "mononobe"(기본, 실무 수리계산서 방식) | "general" +DRAINAGE_RAINFALL_IDF_METHOD = os.getenv("DRAINAGE_RAINFALL_IDF_METHOD", "mononobe") + +# ── 배수 유효직경 계산 계수 (2026-08-05 사용자 확정, 위 규정 조사 문서 6절) ── +# 합리식 유출계수 C. 산악지 준용 0.8 — 필요 시 사용자가 이 값을 고친다. +DRAINAGE_RUNOFF_COEFFICIENT = float(os.getenv("DRAINAGE_RUNOFF_COEFFICIENT", "0.8")) +# 설계유량 배수. 시행규칙 별표2 (가): 최대홍수유출량의 2.0배 이상. +DRAINAGE_DESIGN_FLOW_FACTOR = float(os.getenv("DRAINAGE_DESIGN_FLOW_FACTOR", "2.0")) +# 설계빈도(년). 별표2 (가) 1호: 100년빈도 확률강우량. +DRAINAGE_DESIGN_RETURN_PERIOD_YR = int(os.getenv("DRAINAGE_DESIGN_RETURN_PERIOD_YR", "100")) +# 홍수도달시간 하한(분). 국도건설공사 설계실무요령·도로배수지침 "강우지속기간 5분 원칙". +DRAINAGE_TC_MIN_MINUTES = float(os.getenv("DRAINAGE_TC_MIN_MINUTES", "5.0")) +# 관 경사(도). 시공 중 수시 변경되므로 지형 산출 대신 실무 대푯값 10도로 고정(사용자 제시). +DRAINAGE_PIPE_SLOPE_DEG = float(os.getenv("DRAINAGE_PIPE_SLOPE_DEG", "10.0")) +# Manning 조도계수(파형강관). +DRAINAGE_MANNING_N = float(os.getenv("DRAINAGE_MANNING_N", "0.024")) +# 관내 유속 허용범위(m/s). 도로배수지침 0.8~3.0 — 상한 클램프가 안전측(관이 커진다). +DRAINAGE_VELOCITY_MIN_MS = float(os.getenv("DRAINAGE_VELOCITY_MIN_MS", "0.8")) +DRAINAGE_VELOCITY_MAX_MS = float(os.getenv("DRAINAGE_VELOCITY_MAX_MS", "3.0")) +# 원형관 통수단면 비율. 도로설계요령: 관 단면의 70%만 통수 고려. +DRAINAGE_FLOW_AREA_RATIO = float(os.getenv("DRAINAGE_FLOW_AREA_RATIO", "0.7")) +# 세월교 검토 문턱(mm). 유효직경이 관 최대 규격(파형강관 D2000)을 넘으면 관이 아니라 +# 세월교·물넘이·교량 대상이다 — 「임도설치 및 관리 등에 관한 규정」 제12조: 계류 횡단 +# 구간은 배수구 막힘 우려가 없는 물넘이 포장(세월교) 또는 교량으로 설계(2026-08-05 조사). +DRAINAGE_BRIDGE_THRESHOLD_MM = float(os.getenv("DRAINAGE_BRIDGE_THRESHOLD_MM", "2000")) +# BOX암거 전환 문턱(mm). 교본 "수리계산 Ø1,500㎜ 이상 유역·협곡·횡단경사 40% 이내"에서 +# 유량 조건만 취한 값이다 — 횡단경사·협곡 판정은 지형 계산이 필요해 화면이 "현장 확인"으로 +# 안내한다 (2026-08-17 사용자 확정: 추천은 유량 근거만). +DRAINAGE_BOX_THRESHOLD_MM = float(os.getenv("DRAINAGE_BOX_THRESHOLD_MM", "1500")) +# 추천 관경 규격(mm) — B05 레지스트리 `pipe_diameter_mm` 선택지와 **같아야 한다**. +# 폼에서 고를 수 없는 값을 추천하면 사용자가 그대로 확정하지 못한다. +DRAINAGE_RECOMMEND_DIAMETERS_MM = (800, 1000, 1200, 1500) + +# ── 물넘이포장·세월교 개략 단면 (2026-08-17 실무문서 역산 확인) ── +# 물넘이는 노면 개수로다. 조도계수 0.017은 KDS 표 2.7-1 콘크리트 수로 "보통"값이자 +# 실무 물넘이 관측치와 일치하고, 경사 10%는 실무 수리계산서 역산값이다 — 울진1공구 +# 물넘이 시트(B=20m·h=0.27m·n=0.017 → V=7.636 m/s)와 같은 공사지 관 시트(Ø1000·240° +# 유효단면·n=0.025 → V=5.694 m/s)가 둘 다 S=0.10에서 검산이 맞는다 +# (original/실무문서/_숨김탭분석.md §6-1·§6-2). +FORD_MANNING_N = float(os.getenv("FORD_MANNING_N", "0.017")) +FORD_SLOPE = float(os.getenv("FORD_SLOPE", "0.10")) + +# ── B05 구조물 옵션 (드롭다운 목록·기본값, 2026-08-05 사용자 확정) ── +# 프론트는 이 값을 /api 설정 응답 또는 빌드타임 복사로 받아 쓴다. 수정은 여기서만 한다. +STRUCTURE_TYPES = ("배관", "기성막이", "대피로", "기타") +STRUCTURE_DEFAULT_TYPE = "배관" +PIPE_DIAMETERS_MM = { + "이중벽관": (150, 200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), + "삼중벽관": (200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), + "파형강관": ( + 150, + 200, + 250, + 300, + 350, + 400, + 450, + 500, + 600, + 700, + 800, + 900, + 1000, + 1200, + 1350, + 1500, + 1650, + 1800, + 2000, + ), +} +PIPE_DEFAULT_TYPE = "파형강관" +# 자동 지정 기본 관경. 별표2 (나) 예외 하한 800mm(사용자 결정 — 계산값이 더 크면 바로 위 규격). +PIPE_DEFAULT_DIAMETER_MM = int(os.getenv("PIPE_DEFAULT_DIAMETER_MM", "800")) +ESCAPE_ROUTE_WIDTHS_M = (1.5, 2.0, 2.5, 3.0) +ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0 +STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물" + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4. 종횡단 생성 파라미터 (B06 WF3) +# ───────────────────────────────────────────────────────────────────────── +SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) +# 초기 파이프라인(파일입력→B06) 샘플 반폭 — 표시 반폭보다 넉넉히 뽑아 두면 사용자가 +# 표시 반폭을 이 안에서 바꿀 때 재계산이 필요 없다(2026-08-06 사용자 확정, 기준 20m). +# 표시 반폭이 이 값을 넘을 때만 B05부터 재생성한다. 향후 사용자 평균 설정 보고 조정. +SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "20.0")) +SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) +SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) +SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) +SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" +FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-1. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정) +# +# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값. +# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장). +# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %. +# +# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과 +# 절·성토 단면적 엔진(B06_Section_Engine_Design)이 모두 여기를 읽으며, +# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다. +# ───────────────────────────────────────────────────────────────────────── +STANDARD_CROSS_SECTION = { + "soil": { + "road_width_m": 3.0, # 노폭(차도) + "shoulder_left_m": 0.5, # 노견(좌) + "shoulder_right_m": 0.5, # 노견(우) + # 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm. + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향) + "fill_slope_ratio": 1.2, # 성토 1:1.2 + "cut_slope_ratio": 1.0, # 절토 1:1.0 + }, + "rock": { # 리핑암·발파암 공유 + "road_width_m": 3.0, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + # 일반 측구 = 690/300/300mm. + "ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, + # L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택). + "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, + "cross_slope_pct": {"min": 3.0, "max": 3.0}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상) + }, + "paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름 + "road_width_m": 3.0, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cross_slope_pct": {"min": 1.5, "max": 2.0}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 1.0, + # 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능). + "pavement_thickness_m": 0.2, + }, +} +# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m). +STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5 +STANDARD_ROCK_BOUNDARY_STEP_M = 0.1 + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3) +# +# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지). +# 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종 +# 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분). +# ───────────────────────────────────────────────────────────────────────── +_SCS_SOIL = STANDARD_CROSS_SECTION["soil"] +# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능. +SECTION_ROADBED_WIDTH_M = float( + os.getenv( + "SECTION_ROADBED_WIDTH_M", + str( + _SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"] + ), + ) +) +SECTION_CARRIAGEWAY_WIDTH_M = float( + os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"])) +) +# 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정. +SECTION_FILL_SLOPE_RATIO = float( + os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"])) +) +# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다. +SECTION_GROUND_TYPE_PRESET = { + "soil": "soil", + "ripping_rock": "rock", + "blasting_rock": "rock", +} +# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. +SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") +SECTION_DITCH_SIDES = ("left", "right") +# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지). +SECTION_DITCH_TYPES = ("standard", "l_type") + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-3. 토량환산계수 (B06 유토곡선) +# +# loose(L) = 흐트러진 상태 토량 / 자연상태 토량 (팽창률) +# compacted(C) = 다져진 상태 토량 / 자연상태 토량 (다짐률) +# +# 유토곡선은 운반계획 도면이므로 **다짐상태 기준**으로 계산한다. +# "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, +# 내역서에 적용하는 수량은 자연상태로 한다." +# (2021년도 국도건설공사 설계실무 요령 / 2016 건설공사 표준품셈 계열) +# +# 아래 값은 표준품셈 토량환산계수표의 암종별 범위를 B06 지반유형 3종에 대응시킨 +# 제안값이며 임도 전용 고시 수치가 아니다. +# 토사 ← 풍화토(L 1.10~1.25 / C 0.80~0.90) ~ 점토(L 1.20~1.35 / C 0.75~0.90) +# 리핑암 ← 풍화암(L 1.30~1.35 / C 1.00~1.15) ~ 연암(L 1.30~1.50 / C 1.00~1.30) 하단 +# 발파암 ← 보통암(L 1.55~1.70 / C 1.20~1.40) 중앙 +# 표준품셈도 "토질 시험하여 적용하는 것을 원칙으로 하되 소량인 경우 환산계수표에 +# 따를 수 있다"고 하므로 현장별 조정이 전제다. 값을 바꾸려면 여기만 고치면 된다. +# +# 이 상수가 **토량환산계수의 유일한 정의처**다(타 파일 중복 정의 금지). +# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 8절 +# ───────────────────────────────────────────────────────────────────────── +EARTHWORK_CONVERSION_FACTORS = { + "soil": {"loose": 1.25, "compacted": 0.90}, + "ripping_rock": {"loose": 1.35, "compacted": 1.15}, + "blasting_rock": {"loose": 1.60, "compacted": 1.30}, +} + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획) +# +# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` +# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. +# +# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 60m < 덤프트럭 +# +# 2026-09-07 사용자 확정값(도쟈 70 → 60m). 근거 — 실무 오솔길 `EARTH.DAT` 헤더 6개 +# 공사지가 전부 `20.0 / 60.0`(거창·장수·진안·봉화·영월 본선·지선), 산림과임업기술 +# 5장 「다. 공사수량의 산출」이 「도저운반성토 60m 이하 / 덤프운반성토 60m 초과 +# (건설표준품셈 참조)」로 못 박음. 70m 는 Aislo 단독값이었음(2026-08-02 잠정 확정). +# 유토곡선의 장비 경계현은 이 값을 +# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, +# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. +# 현장·발주처 기준에 따라 달라질 수 있으므로 +# **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다. +# +# key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프. +# 화면 표기 문구는 하드코딩 금지 규칙에 따라 ui_locales에서 따로 관리한다. +# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md +# ───────────────────────────────────────────────────────────────────────── +EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( + ("free_haul", 20.0), + ("dozer", 60.0), + ("dump_truck", None), +) + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-2. 자연방토 판정 경사 (B06 유토곡선) +# +# 자연방토 = 남는 흙을 성토사면 아래로 흘려보내 **운반비를 세지 않는** 처리. +# 판정 기준은 **성토측 자연 지반의 경사**다(2026-08-02 사용자 확정). 지반이 이보다 가파르면 +# 부어 놓은 흙이 쌓이지 않고 스스로 흘러내리므로 따로 운반하지 않는다. +# +# 값은 rise/run(무차원). 1/1.5 = 0.667 ≈ 33.7°로, 토사 안식각 상한이자 표준 성토 경사 1:1.5와 +# 같은 자리다. 현장·발주처 기준에 따라 달라질 수 있으므로 **이 상수가 유일한 정의처**다. +# ───────────────────────────────────────────────────────────────────────── +NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 + + +# ───────────────────────────────────────────────────────────────────────── +# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) +# +# 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준. +# 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어 +# 등급은 grade_to_design_speed로 간접 매핑한다. +# 위 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": { + 40: { + "max_grade_pct": {"normal": 7.0, "special": 10.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 450.0, + "min_curve_length_m": 40.0, + }, + 30: { + "max_grade_pct": {"normal": 8.0, "special": 12.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 250.0, + "min_curve_length_m": 30.0, + }, + 20: { + "max_grade_pct": {"normal": 9.0, "special": 14.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 100.0, + "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 고정이다. 사용자가 화면에서 고른 + # 설계속도가 오면 그 값이 우선한다(design_speed_kph). + # trunk = 간선임도 / fire = 산불진화임도 / work = 작업임도 + # branch(지선)는 현행 규칙에서 폐지 — 기존 데이터 호환용으로만 남긴다. + "grade_to_design_speed": {"trunk": 20, "fire": 20, "work": 20, "branch": 20}, + # 화면에서 고를 수 있는 설계속도 — 작업임도는 20만 허용된다(별표2 Ⅰ.3). + "selectable_design_speeds": {"trunk": (20, 30, 40), "fire": (20, 30, 40), "work": (20,)}, + # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 + "paved_exception_grade_pct": 18.0, + # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 + "vertical_curve_skip_delta_pct": 5.0, + # 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값) + "min_tangent_length_m": 20.0, + # 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다) + "balance_segment_length_m": None, + # 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면 + # 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다. + # V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다. + "main_direction_monotone_ratio": 0.5, +} +GRADE_TERRAIN_TYPES = ("normal", "special") +# 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용) +GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") + + +# ───────────────────────────────────────────────────────────────────────── +# 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2) +# +# 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을 +# "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로 +# 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다. +# +# 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로 +# 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다. +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_PROFILE_ALIGNMENT = { + # 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%) + # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 + # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. + "balance_tolerance_percent": 10.0, + # 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치). + # R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다). + # L = R × |기울기 대수차 A(비율)| + # 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로 + # 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다 + # (예: 20.0 → R=400m, A=10%일 때 L=40m). + "curve_radius_ratio": 0.40, + # **기본 종단곡선 길이 L(m) — 모든 변화점의 1차 기준값.** + # 임도 계획선은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면 + # L = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다 + # (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다. + # 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다. + # 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만 + # 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시). + # 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다. + "default_curve_length_m": 15.0, + # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. + # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. + "curve_tangent_max_ratio": 0.45, + # 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지. + # 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가 + # 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고 + # 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다). + "curve_skip_legal_exception": False, + # 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²). + # 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다. + "pvi_penalty_m2": 25.0, + # 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지) + "min_segment_stations": 2, + # 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량. + "edit_step_m": 0.1, + # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) + "grade_violation_policy": "warn", +} diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py new file mode 100644 index 00000000..113e576e --- /dev/null +++ b/config/config_system_terrain.py @@ -0,0 +1,201 @@ +"""지형 분석·지표면 모델 생성 파라미터 (5장·5-2장). + +`config_system.py` 가 700줄을 넘어 떼어냈다(2026-09-04). 값·이름은 그대로이고, +`config_system` 이 다시 내보내므로 기존 `from config.config_system import …` 는 불변이다. +""" + +import os + +# 5. 지형 분석 알고리즘 파라미터 +# ───────────────────────────────────────────────────────────────────────── +# Trimesh 메쉬 생성 +MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 미터 단위 +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) 지면 분류 파라미터 +SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5")) +SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1")) +SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65")) +SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5")) +# 낙하 후 스프링 정착에 쓰는 여유 반복 수 (실제 낙하 구간은 현장 기복에서 계산한다) +SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150")) +# 낙하+정착 반복 상한 — 기복 1500m(0.3185m/회)까지 커버한다 +SURFACE_CSF_MAX_ITERATIONS = int(os.getenv("SURFACE_CSF_MAX_ITERATIONS", "5000")) +SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true" +SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float( + os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8") +) + +# PMF (Progressive Morphological Filter) 지면 분류 파라미터 +SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0")) +SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40")) +SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3")) +SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0")) +SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5")) + +# RANSAC (Local plane fitting) 지면 분류 파라미터 +SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3")) +SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3")) +SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100")) +SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0")) +SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) + +# LAS 자체 지면분류(class 2) 필터 — 이 비율 이상 분류돼 있을 때만 필터로 제공한다. +# 미분류 LAS는 classification이 전부 0이라 마스크가 비어 버린다. +SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( + os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002") +) +# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. +SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) + +# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). +# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 +# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). +# 30m 는 너무 많이 깎는다는 지적으로 3m 로 낮춤 (2026-09-04 사용자 지시). +SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "3.0")) + +# ───────────────────────────────────────────────────────────────────────── +# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) +# ───────────────────────────────────────────────────────────────────────── +# 고를 수 있는 지면 필터 전체 — 관리자가 B04 드롭다운에서 요청할 때 쓰인다. +# 자동 전처리가 미리 만드는 조합이 아니다(그건 SURFACE_AUTO_* 가 정한다). +SURFACE_MODEL_SOURCE_FILTERS = tuple( + os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "classification,grid_min_z,csf,pmf").split(",") +) +# 자동 전처리가 만드는 조합 — 기본 필터 1종 × 아래 표현. 나머지는 관리자 요청 시 만든다. +SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(",")) +# 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값. +SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "csf") +# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) +SURFACE_MODEL_PRECOMPUTE = tuple( + os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") +) +SURFACE_MODEL_SMOOTHING_METHODS = tuple( + os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") +) +SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0")) + +# footprint(외곽) 산출 +SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) +SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) +SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) +SURFACE_KEEP_LARGEST_FOOTPRINT = ( + os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" +) +SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) +SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000")) + +# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F) +SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000")) +SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) +SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) +SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) +SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( + os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") +) +SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( + os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") +) +SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) +SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) +SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) + +# 스무딩 파라미터 +SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) +SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0")) +SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float( + os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5") +) +SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int( + os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10") +) +SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5")) +SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) + +# 등고선 파라미터 +SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) +SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) + +# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정) +# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값). +SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0")) +# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값). +SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0")) +# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다 +# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py. +SHEET_SURFACE_METHODS = [ + method.strip() + for method in os.getenv( + "SHEET_SURFACE_METHODS", + "tin_sheet,tin,biharmonic,anudem,multires,laplace", + ).split(",") + if method.strip() +] +# 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). +SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") +# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 +# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서 +# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정). +SHEET_SURFACE_AUTO_METHODS = [ + method.strip() + for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",") + if method.strip() +] + +# 일반 사용자 WF1 자동 확정 기본값 +SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") +SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") +SURFACE_CONFIRM_DEFAULT_SMOOTH = ( + os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" +) + + +def build_surface_model_config() -> dict: + """지표면 모델 파이프라인이 사용하는 config dict를 조립한다.""" + return { + "source_filters": list(SURFACE_MODEL_SOURCE_FILTERS), + "precompute": list(SURFACE_MODEL_PRECOMPUTE), + "smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS), + "sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS, + "footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M, + "footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M, + "boundary_inset_meters": SURFACE_BOUNDARY_INSET_M, + "keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT, + "tile_size_meters": SURFACE_TILE_SIZE_M, + "max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES, + "tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS, + "dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M, + "nurbs_degree": SURFACE_NURBS_DEGREE, + "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, + "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, + "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, + "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, + "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, + "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, + "smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M, + "smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, + "smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, + "smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS, + "smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA, + "smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU, + "contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M, + "contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M, + } 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/014_company_logo.sql b/db_management/014_company_logo.sql new file mode 100644 index 00000000..8c34b99f --- /dev/null +++ b/db_management/014_company_logo.sql @@ -0,0 +1,20 @@ +-- 014_company_logo.sql +-- 표제란 사람·서명·로고 재배치 (2026-09-02 사용자 확정) +-- +-- ① 서명은 프로젝트가 아니라 **사람(계정)에 붙는다** — 이름이 들어가는 자리 +-- (과업책임자·분야별책임자·설계자)의 서명을 그 사람 계정에서 읽는다. +-- 표현 수단은 013 의 `company_assets` 그대로다: `kind='SIGNATURE'` + `user_id=<그 사람>`. +-- 그래서 **서명 쪽은 새 컬럼이 없다**. +-- ② 회사 로고는 **회사 등록 단계**에서 받고 이후 수정·변경한다 — 회사마다 대표 로고 1벌을 +-- 가리키는 자리가 필요해 이 컬럼을 만든다(`kind='LOGO'` + `user_id IS NULL`). +-- ③ 프로젝트가 고른 로고(`projects.logo_asset_id`)는 그대로 두고 **덮어쓰기**로 남긴다 — +-- 비어 있으면 회사 로고를 쓴다. +-- +-- `projects.signature_asset_id`(013)는 이제 읽지 않는다. 컬럼은 남겨 둔다 +-- (4환경 공유 DB 라 지우는 쪽이 위험하다 — 013 과 같은 판단). + +USE aislo_db; + +ALTER TABLE companies + ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL + COMMENT '회사 대표 로고 (company_assets.id, kind=LOGO)'; diff --git a/db_management/015_route_range.sql b/db_management/015_route_range.sql new file mode 100644 index 00000000..b1d48b4c --- /dev/null +++ b/db_management/015_route_range.sql @@ -0,0 +1,16 @@ +-- 015_route_range.sql +-- 계획노선 사용 범위 (2026-09-04 사용자 지시) +-- +-- 계획노선 자료가 공사지 전체일 수 있어 **어느 구간을 쓸지 사용자가 정한다**. +-- B02 등록 화면에서 시작·종료 누가거리(m)를 받고, B03 이 계획노선을 세울 때 이 범위로 +-- 먼저 자른 뒤 서피스 밖을 잘라 낸다(순서가 바뀌면 사용자가 정한 시점이 밀린다). +-- +-- 둘 다 NULL 이면 지금처럼 **전 구간**을 쓴다 — 기존 행·기존 동작은 그대로다. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS route_start_m DOUBLE NULL + COMMENT '계획노선 시작 누가거리(m) — NULL 이면 처음부터' AFTER estimated_length_m, + ADD COLUMN IF NOT EXISTS route_end_m DOUBLE NULL + COMMENT '계획노선 종료 누가거리(m) — NULL 이면 끝까지' AFTER route_start_m; 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/db_management/019_drop_legacy_audit_logs.sql b/db_management/019_drop_legacy_audit_logs.sql new file mode 100644 index 00000000..0cbd4343 --- /dev/null +++ b/db_management/019_drop_legacy_audit_logs.sql @@ -0,0 +1,16 @@ +-- 019_drop_legacy_audit_logs.sql +-- 안 쓰는 빈 표 `audit_logs` 를 지운다 (계획서 0-8 곁가지, 2026-09-07 사용자 지시). +-- +-- 왜 — 감사 기록의 **정본은 `system_audit_logs`** 다(`004_dashboard.sql`). `audit_logs` 는 +-- 최초 스키마(`001_create_schema.sql`)에만 있고 **코드가 한 곳도 쓰지 않는다** +-- (쓰기 `common_util_audit.py:61`, 읽기 `B01_Dashboard_Repository.py:455·460`, 정리 +-- `purge_expired_audit_logs` 모두 `system_audit_logs`). 표가 둘이라 「감사 기록이 비었다」는 +-- 오진이 실제로 한 번 났다(2026-09-07). +-- +-- 지우기 전 확인(2026-09-07, 공용 DB) — `audit_logs` **0행** · `system_audit_logs` 81행 · +-- 이 표를 참조하는 **외래키 0건**. 그래서 지워도 잃는 자료가 없다. +-- +-- ⚠ 최초 스키마 파일은 **고치지 않았다** — 지나간 이력이라 다시 쓰지 않는 것이 규칙이다. +-- 새로 설치하면 001 이 만들고 이 파일이 지운다. + +DROP TABLE IF EXISTS audit_logs; diff --git a/db_management/tools_clone_project.py b/db_management/tools_clone_project.py new file mode 100644 index 00000000..520a22d2 --- /dev/null +++ b/db_management/tools_clone_project.py @@ -0,0 +1,267 @@ +"""프로젝트 통째 복제 — **검증용 프로젝트를 창마다 하나씩 뜨는 도구** (2026-09-08). + +⚠ **왜 저장소에 두나** — 네 창이 **같은 원본에서 같은 방법으로** 떠야 수치를 견줄 수 있다. + `tmp/` 는 창마다 따로라 서로 못 봄(2026-09-08 랩탑 두 창이 막힌 자리). + +⚠ **원본은 읽기만 한다** — SELECT 뿐이고 원본 행·파일에 쓰지 않는다. + ⚠ 다만 **복제 중에 원본이 바뀌면 창마다 복제본이 달라진다** — 원본을 얼린 뒤 뜰 것. + +무엇을 뜨나 + DB projects · input_files · processed_point_cloud · surface_models · routes · + route_points · route_statistics · longitudinal_sections · cross_sections · + project_workflow_stages + ⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다. + 파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다. + 치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**. + +⚠ **DB 만 뜨거나 파일만 떠서는 안 된다** — 수량(B08)은 DB(측점·횡단)를 보고, + 구조물·관 정본은 파일(`structures.json`·`pipe_points.json`)이라 **둘 다** 있어야 화면이 선다. + +쓰는 법 + ./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>" +""" + +import argparse +import asyncio +import json +import subprocess +import sys +import time +from pathlib import Path +from uuid import uuid4 + +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +import aiomysql + +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).") + parser.add_argument("source_id", help="원본 프로젝트 UUID") + parser.add_argument("new_name", help="새 프로젝트 이름") + return parser.parse_args() + + +ARGS = _args() +SOURCE_ID = ARGS.source_id +NEW_NAME = ARGS.new_name + + +async def _rows(cursor, sql, args): + await cursor.execute(sql, args) + return await cursor.fetchall() + + +async def _insert(cursor, table, row: dict) -> int: + names = ", ".join(f"`{key}`" for key in row) + holders = ", ".join(["%s"] * len(row)) + await cursor.execute(f"INSERT INTO `{table}` ({names}) VALUES ({holders})", tuple(row.values())) + return int(cursor.lastrowid) + + +async def main() -> None: + connection = await aiomysql.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + db=DB_NAME, + charset="utf8mb4", + ) + try: + async with connection.cursor(aiomysql.DictCursor) as cursor: + source = (await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (SOURCE_ID,)))[0] + input_files = await _rows( + cursor, "SELECT * FROM input_files WHERE project_id = %s ORDER BY id", (SOURCE_ID,) + ) + clouds = await _rows( + cursor, + "SELECT * FROM processed_point_cloud WHERE project_id = %s ORDER BY id", + (SOURCE_ID,), + ) + models = await _rows( + cursor, + "SELECT * FROM surface_models WHERE project_id = %s ORDER BY id", + (SOURCE_ID,), + ) + routes = await _rows( + cursor, "SELECT * FROM routes WHERE project_id = %s ORDER BY id", (SOURCE_ID,) + ) + stages = await _rows( + cursor, + "SELECT * FROM project_workflow_stages WHERE project_id = %s ORDER BY stage_no", + (SOURCE_ID,), + ) + route_ids = [route["id"] for route in routes] + points: list = [] + statistics: list = [] + longitudinal: list = [] + crosses: list = [] + if route_ids: + holders = ", ".join(["%s"] * len(route_ids)) + points = await _rows( + cursor, + f"SELECT * FROM route_points WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + statistics = await _rows( + cursor, + f"SELECT * FROM route_statistics WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + longitudinal = await _rows( + cursor, + "SELECT * FROM longitudinal_sections" + f" WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + crosses = await _rows( + cursor, + f"SELECT * FROM cross_sections WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + + new_id = str(uuid4()) + source_root = Path(resolve_stored_project_path(source["storage_path"])) + new_storage = "/".join(source["storage_path"].split("/")[:-1] + [new_id]) + new_root = source_root.parent / new_id + + # 1) 파일 먼저 - 실패하면 DB 를 건드리지 않은 채로 끝난다. + print(f"파일 복사 시작: {source_root.name} -> {new_id}") + started = time.perf_counter() + result = subprocess.run( + [ + "robocopy", + str(source_root), + str(new_root), + "/E", + "/NFL", + "/NDL", + "/NJH", + "/NJS", + "/MT:8", + ], + capture_output=True, + text=True, + ) + if result.returncode >= 8: + raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})") + print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초") + + # 2) DB - 한 트랜잭션으로. + await connection.begin() + async with connection.cursor(aiomysql.DictCursor) as cursor: + project = dict(source) + project["id"] = new_id + project["name"] = NEW_NAME + project["storage_path"] = new_storage + await _insert(cursor, "projects", project) + + file_map: dict[int, int] = {} + for row in input_files: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + file_map[old] = await _insert(cursor, "input_files", row) + + cloud_map: dict[int, int] = {} + for row in clouds: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + row["input_file_id"] = file_map.get(row["input_file_id"], row["input_file_id"]) + cloud_map[old] = await _insert(cursor, "processed_point_cloud", row) + + model_map: dict[int, int] = {} + for row in models: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + row["source_file_id"] = file_map.get(row["source_file_id"], row["source_file_id"]) + row["processed_cloud_id"] = cloud_map.get( + row["processed_cloud_id"], row["processed_cloud_id"] + ) + model_map[old] = await _insert(cursor, "surface_models", row) + + route_map: dict[int, int] = {} + for row in routes: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + row["surface_model_id"] = model_map.get( + row["surface_model_id"], row["surface_model_id"] + ) + route_map[old] = await _insert(cursor, "routes", row) + + for table, rows in ( + ("route_points", points), + ("route_statistics", statistics), + ("longitudinal_sections", longitudinal), + ("cross_sections", crosses), + ): + for row in rows: + row = dict(row) + row.pop("id") + row["route_id"] = route_map.get(row["route_id"], row["route_id"]) + if "project_id" in row: + row["project_id"] = new_id + await _insert(cursor, table, row) + + for row in stages: + row = dict(row) + row.pop("id") + row["project_id"] = new_id + params = row.get("params") + if params: + # 단계 설정에 박힌 옛 id 를 새 id 로 바꾼다(입력 파일·지표면 모델). + data = json.loads(params) + if data.get("input_file_id") is not None: + old_file = int(data["input_file_id"]) + data["input_file_id"] = str(file_map.get(old_file, old_file)) + if data.get("surface_model_id") is not None: + old_model = int(data["surface_model_id"]) + data["surface_model_id"] = model_map.get(old_model, old_model) + row["params"] = json.dumps(data, ensure_ascii=False) + await _insert(cursor, "project_workflow_stages", row) + await connection.commit() + print( + f"DB 복제 완료: 입력파일 {len(file_map)} · 지표면 {len(model_map)}" + f" · 노선 {len(route_map)}" + f" · 정점 {len(points)} · 횡단 {len(crosses)} · 단계 {len(stages)}" + ) + + # 3) 파일 안에 박힌 옛 프로젝트 id 치환 + 초기값 스냅샷의 지표면 모델 id 교정. + replaced = [] + for path in new_root.rglob("*.json"): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if SOURCE_ID not in text: + continue + path.write_text(text.replace(SOURCE_ID, new_id), encoding="utf-8") + replaced.append(str(path.relative_to(new_root))) + print("프로젝트 id 치환:", replaced) + + snapshot = new_root / "initial_snapshot" / "db.json" + if snapshot.is_file(): + dump = json.loads(snapshot.read_text(encoding="utf-8")) + changed = False + for route in dump.get("routes", []): + old_model = route.get("surface_model_id") + if old_model in model_map: + route["surface_model_id"] = model_map[old_model] + changed = True + if changed: + snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") + print("초기값 스냅샷 지표면 id 교정:", changed) + + print(f"\n새 프로젝트: {NEW_NAME}\n id = {new_id}\n 저장소 = {new_storage}") + finally: + connection.close() + + +asyncio.run(main()) diff --git a/main.py b/main.py index af402284..08689bd8 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,26 @@ 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 B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router +from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router +from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from common_util.common_util_audit import note_api_call, record_call_burst + +# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 +# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). +from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_auth import ( require_company, require_project_access, @@ -68,6 +85,7 @@ from config.config_system import ( LOG_LEVEL, SERVER_HOST, SERVER_PORT, + SESSION_COOKIE_NAME, STATIC_DIR, STATIC_URL, ) @@ -262,6 +280,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 +307,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 +356,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 +422,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 +460,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 +534,79 @@ 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) +app.include_router(b08_earthwork_router, dependencies=protected_with_company) +app.include_router(b08_material_router, dependencies=protected_with_company) +app.include_router(b09_estimation_router, dependencies=protected_with_company) +# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). +# 그 위에 서버가 환경까지 한 번 더 본다. +app.include_router(dev_unlock_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 +616,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/package.json b/package.json index 00290cfa..7e20859b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:b07-cad", + "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", + "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", 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/data_cost_machine_operating/machine_operating_2026.json b/resources/data_cost_machine_operating/machine_operating_2026.json new file mode 100644 index 00000000..6f789382 --- /dev/null +++ b/resources/data_cost_machine_operating/machine_operating_2026.json @@ -0,0 +1,1044 @@ +{ + "dataset_id": "machine_operating_derived", + "derived_from": { + "dataset_id": "mach_base", + "effective_date": "2026-01-01", + "sha256": "b66e9f658da2ed4a02931b3a220cb81f7bce0ba845b92d7562ae2c27b62dda1f" + }, + "dropped_tables": [ + "8-4 운전경비 산정('08, '09, '10, '11, '12, '13 (개수 불일치)", + "8-4-3 [20]운반 및 하역기계('21년 보완) (개수 불일치)", + "8-4-3 [20]운반 및 하역기계('21년 보완) (개수 불일치)", + "8-4-4 [30]포장기계 (개수 불일치)", + "8-4-5 [40]콘크리트기계 (개수 불일치)", + "8-4-5 [40]콘크리트기계 (개수 불일치)", + "8-4-6 [50]골재생산기계 등 (개수 불일치)", + "8-4-8 [70]기타기계('24년 보완) (개수 불일치)", + "8-4-8 [70]기타기계('24년 보완) (개수 불일치)", + "5801-0045 (기종 카탈로그에 없음)" + ], + "note": "건설품셈 8-4 운전경비 산정에서 뽑은 파생본. 기준자료가 아니다 — `mach_fuel_rate`·`mach_operator_map` 이 채워지면 이 파일은 걷어낸다.", + "policy": { + "operator_mapping_is_provisional": true, + "operator_mapping_rule": "트럭 계열 = 화물차운전사, 그 밖 = 건설기계운전사 (잠정)" + }, + "records": [ + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "9.0", + "machine_code": "0101-0007", + "machine_name": "불도저(무한궤도)", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "7" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "12.5", + "machine_code": "0101-0010", + "machine_name": "불도저(무한궤도)", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "10" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "14.6", + "machine_code": "0101-0012", + "machine_name": "불도저(무한궤도)", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "12" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "25.0", + "machine_code": "0101-0019", + "machine_name": "불도저(무한궤도)", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "19" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "41.6", + "machine_code": "0101-0032", + "machine_name": "불도저(무한궤도)", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "32" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "19.2", + "machine_code": "0102-0015", + "machine_name": "불도저(타이어)", + "misc_material_percent": "50", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "15" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "36.0", + "machine_code": "0102-0028", + "machine_name": "불도저(타이어)", + "misc_material_percent": "50", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "28" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "42.4", + "machine_code": "0102-0033", + "machine_name": "불도저(타이어)", + "misc_material_percent": "50", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "33" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "5.4", + "machine_code": "0121-0004", + "machine_name": "습지 불도저", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "4" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "14.6", + "machine_code": "0121-0013", + "machine_name": "습지 불도저", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "13" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "3.2", + "machine_code": "0201-0012", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.12" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "5.0", + "machine_code": "0201-0020", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.2" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "9.9", + "machine_code": "0201-0040", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.4" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "10.2", + "machine_code": "0201-0060", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.6" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.6", + "machine_code": "0201-0070", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.7" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "15.3", + "machine_code": "0201-0080", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.8" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "19.5", + "machine_code": "0201-0100", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.0" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "20.2", + "machine_code": "0201-0120", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.2" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "32.8", + "machine_code": "0201-0200", + "machine_name": "굴착기(무한궤도)", + "misc_material_percent": "22", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "2.0" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "5.6", + "machine_code": "0211-0018", + "machine_name": "굴착기(타이어)", + "misc_material_percent": "24", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.18" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.6", + "machine_code": "0211-0060", + "machine_name": "굴착기(타이어)", + "misc_material_percent": "24", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.6" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "16.3", + "machine_code": "0211-0080", + "machine_name": "굴착기(타이어)", + "misc_material_percent": "24", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.8" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "20.5", + "machine_code": "0211-0100", + "machine_name": "굴착기(타이어)", + "misc_material_percent": "24", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.0" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "9.5", + "machine_code": "0221-0040", + "machine_name": "습지굴착기(무한궤도)", + "misc_material_percent": "15", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.4" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.0", + "machine_code": "0221-0070", + "machine_name": "습지굴착기(무한궤도)", + "misc_material_percent": "15", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.7" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "6.7", + "machine_code": "0260-0355", + "machine_name": "트랜처", + "misc_material_percent": "34", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "3.55" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "4.8", + "machine_code": "0301-0057", + "machine_name": "로더(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.57" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "6.3", + "machine_code": "0301-0076", + "machine_name": "로더(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.76" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "7.4", + "machine_code": "0301-0095", + "machine_name": "로더(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.95" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "9.5", + "machine_code": "0301-0115", + "machine_name": "로더(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.15" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.3", + "machine_code": "0301-0134", + "machine_name": "로더(무한궤도)", + "misc_material_percent": "21", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.34" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "7.6", + "machine_code": "1106-0010", + "machine_name": "머캐덤 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "8∼10" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "9.3", + "machine_code": "1106-0012", + "machine_name": "머캐덤 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "10∼12" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "10.9", + "machine_code": "1106-0015", + "machine_name": "머캐덤 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "12∼15" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "5.0", + "machine_code": "1206-0008", + "machine_name": "탠덤롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "5∼8" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "6.8", + "machine_code": "1206-0010", + "machine_name": "탠덤롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "8∼10" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "8.4", + "machine_code": "1206-0014", + "machine_name": "탠덤롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "10∼14" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "2.5", + "machine_code": "1209-0001", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "4.1", + "machine_code": "1209-0002", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "2" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "8.2", + "machine_code": "1209-0004", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "4" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "10.2", + "machine_code": "1209-0006", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "6" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.2", + "machine_code": "1209-0007", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "7" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.2", + "machine_code": "1209-0008", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "8" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "16.8", + "machine_code": "1209-0013", + "machine_name": "탠덤롤러(진동 자주식)", + "misc_material_percent": "8", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "13" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "2.2", + "machine_code": "1305-0007", + "machine_name": "진동롤러(핸드가이드식)", + "misc_material_percent": "13", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "0.7" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "2.3", + "machine_code": "1306-0025", + "machine_name": "진동롤러(자주식)", + "misc_material_percent": "13", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "2.5" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "3.2", + "machine_code": "1306-0044", + "machine_name": "진동롤러(자주식)", + "misc_material_percent": "13", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "4.4" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.6", + "machine_code": "1306-0060", + "machine_name": "진동롤러(자주식)", + "misc_material_percent": "30", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "6" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "14.4", + "machine_code": "1306-0100", + "machine_name": "진동롤러(자주식)", + "misc_material_percent": "30", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "10" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "15.8", + "machine_code": "1306-0120", + "machine_name": "진동롤러(자주식)", + "misc_material_percent": "30", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "12" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "4.9", + "machine_code": "1406-0008", + "machine_name": "타이어 롤러(자주식)", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "5∼8" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "8.0", + "machine_code": "1406-0015", + "machine_name": "타이어 롤러(자주식)", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "8∼15" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "10.0", + "machine_code": "1406-0025", + "machine_name": "타이어 롤러(자주식)", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "15∼25" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.3", + "machine_code": "1506-0011", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "11" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "13.7", + "machine_code": "1506-0012", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "12" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "22.5", + "machine_code": "1506-0015", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "15" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "27.2", + "machine_code": "1506-0019", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "19" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "27.2", + "machine_code": "1506-0025", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "25" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "32.6", + "machine_code": "1506-0030", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "30" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "35.2", + "machine_code": "1506-0032", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "32" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "41.4", + "machine_code": "1506-0037", + "machine_name": "양족식 롤러(자주식)", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "37" + }, + { + "fuel_kind": "휘발유", + "fuel_liters_per_hour": "0.7", + "machine_code": "1630-0080", + "machine_name": "래 머", + "misc_material_percent": "10", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "80" + }, + { + "fuel_kind": "휘발유", + "fuel_liters_per_hour": "1.0", + "machine_code": "1730-0015", + "machine_name": "플레이트 콤팩터", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.5" + }, + { + "fuel_kind": "", + "fuel_liters_per_hour": null, + "machine_code": "5401-0015", + "machine_name": "크롤러드릴(공기식)", + "misc_material_percent": null, + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "15(120㎜)" + }, + { + "fuel_kind": "", + "fuel_liters_per_hour": null, + "machine_code": "5401-0017", + "machine_name": "크롤러드릴(공기식)", + "misc_material_percent": null, + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "17(120㎜)" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "18.6", + "machine_code": "5405-0110", + "machine_name": "크롤러드릴(탑승유압식)", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "110" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "25.7", + "machine_code": "5405-0150", + "machine_name": "크롤러드릴(탑승유압식)", + "misc_material_percent": "23", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "150" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "13.9", + "machine_code": "5701-0010", + "machine_name": "노면파쇄기", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.0" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "52.7", + "machine_code": "5701-0020", + "machine_name": "노면파쇄기", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "2.0" + }, + { + "fuel_kind": "", + "fuel_liters_per_hour": null, + "machine_code": "5805-0002", + "machine_name": "점보드릴", + "misc_material_percent": "6", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "2" + }, + { + "fuel_kind": "", + "fuel_liters_per_hour": null, + "machine_code": "5805-0003", + "machine_name": "점보드릴", + "misc_material_percent": "10", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "3" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "7.3", + "machine_code": "6330-0015", + "machine_name": "디젤 파일 해머", + "misc_material_percent": "36", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "1.5" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "11.8", + "machine_code": "6330-0022", + "machine_name": "디젤 파일 해머", + "misc_material_percent": "36", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "2.2" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "15.5", + "machine_code": "6330-0032", + "machine_name": "디젤 파일 해머", + "misc_material_percent": "36", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "3.2" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "20.0", + "machine_code": "6330-0040", + "machine_name": "디젤 파일 해머", + "misc_material_percent": "36", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "4.0" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "25.0", + "machine_code": "6540-0131", + "machine_name": "", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": null, + "specification": "96" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "15.4", + "machine_code": "6630-0003", + "machine_name": "유압 파일 해머", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": null, + "specification": "3" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "19.3", + "machine_code": "6630-0005", + "machine_name": "유압 파일 해머", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": null, + "specification": "5" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "24.0", + "machine_code": "6630-0007", + "machine_name": "유압 파일 해머", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": null, + "specification": "7" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "31.8", + "machine_code": "6630-0010", + "machine_name": "유압 파일 해머", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": null, + "specification": "10" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "42.3", + "machine_code": "6630-0013", + "machine_name": "유압 파일 해머", + "misc_material_percent": "18", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": null, + "specification": "13" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "29.8", + "machine_code": "6701-0147", + "machine_name": "PBD천공기(유압식)", + "misc_material_percent": "15", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "147㎾, 38m" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "37.5", + "machine_code": "6701-0184", + "machine_name": "PBD천공기(유압식)", + "misc_material_percent": "15", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "184㎾, 53m" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "16.3", + "machine_code": "6801-0010", + "machine_name": "고압분사전용장비", + "misc_material_percent": "16", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "20ton" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "9.02", + "machine_code": "6802-0040", + "machine_name": "", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "40" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "13.30", + "machine_code": "6802-0060", + "machine_name": "", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "60" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "18.69", + "machine_code": "6802-0100", + "machine_name": "", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "100" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "20.61", + "machine_code": "6802-0120", + "machine_name": "", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "120" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "21.85", + "machine_code": "6802-0135", + "machine_name": "", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "135" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "23.65", + "machine_code": "6802-0160", + "machine_name": "", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "160" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "12", + "machine_code": "6803-0100", + "machine_name": "다짐말뚝 전용장비", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "100" + }, + { + "fuel_kind": "경유", + "fuel_liters_per_hour": "19.1", + "machine_code": "6803-0120", + "machine_name": "다짐말뚝 전용장비", + "misc_material_percent": "20", + "operator_mapping_is_provisional": true, + "operator_occupation_code": "1048", + "operator_person_days": "1", + "specification": "120" + } + ], + "schema_version": "1.0", + "stats": { + "dropped": 10, + "records": 92 + } +} \ No newline at end of file diff --git a/resources/data_cost_resource_axis/resource_axis_2026-01-01.json b/resources/data_cost_resource_axis/resource_axis_2026-01-01.json new file mode 100644 index 00000000..fd37f80c --- /dev/null +++ b/resources/data_cost_resource_axis/resource_axis_2026-01-01.json @@ -0,0 +1,6301 @@ +{ + "dataset_id": "resource_axis_forest", + "effective_date": "2026-01-01", + "policy": { + "axis": "resource_only", + "material_amounts_are_before_surcharge": true, + "work_item_axis_owner": "B08" + }, + "rows": [ + { + "alternative_amount": null, + "amount": "2.14", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0087", + "raw_row_index": 1, + "resource_code": "1037", + "resource_kind": "labor", + "resource_name": "벌목부", + "resource_spec": "", + "variant": "5m 미만", + "work_item_code": "FP-04-02-02" + }, + { + "alternative_amount": null, + "amount": "0.51", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0087", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "5m 미만", + "work_item_code": "FP-04-02-02" + }, + { + "alternative_amount": null, + "amount": "2.80", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0087", + "raw_row_index": 1, + "resource_code": "1037", + "resource_kind": "labor", + "resource_name": "벌목부", + "resource_spec": "", + "variant": "5m이상~8m미만", + "work_item_code": "FP-04-02-02" + }, + { + "alternative_amount": null, + "amount": "0.66", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0087", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "5m이상~8m미만", + "work_item_code": "FP-04-02-02" + }, + { + "alternative_amount": null, + "amount": "3.65", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0087", + "raw_row_index": 1, + "resource_code": "1037", + "resource_kind": "labor", + "resource_name": "벌목부", + "resource_spec": "", + "variant": "8m 이상", + "work_item_code": "FP-04-02-02" + }, + { + "alternative_amount": null, + "amount": "0.87", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0087", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "8m 이상", + "work_item_code": "FP-04-02-02" + }, + { + "alternative_amount": null, + "amount": "0.42", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0089", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-04-04" + }, + { + "alternative_amount": null, + "amount": "0.084", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.3m 미만", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.168", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.3∼0.7m 이하", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.264", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.8∼1.1m 이하", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.408", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "1.2∼1.5m 이하", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.012", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.3m 미만", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.036", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.3∼0.7m 이하", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.048", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.8∼1.1m 이하", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.072", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0092", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1.2∼1.5m 이하", + "work_item_code": "FP-05-01-01" + }, + { + "alternative_amount": null, + "amount": "0.072", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "1.0 이하", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.012", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1.0 이하", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.084", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "1.1∼1.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.024", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1.1∼1.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.096", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 3, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "1.6∼2.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.024", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1.6∼2.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.120", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 4, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "2.1∼2.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.036", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "2.1∼2.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.132", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 5, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "2.6∼3.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.036", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 5, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "2.6∼3.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.156", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 6, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "3.1∼3.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.036", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 6, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "3.1∼3.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.180", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 7, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "3.6∼4.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.048", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 7, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "3.6∼4.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.204", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 8, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "4.1∼4.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.048", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 8, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "4.1∼4.5", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.228", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 9, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "4.6∼5.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.060", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0093", + "raw_row_index": 9, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "4.6∼5.0", + "work_item_code": "FP-05-01-02" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0096", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "줄떼", + "work_item_code": "FP-05-01-04" + }, + { + "alternative_amount": null, + "amount": "0.06", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0096", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "평떼", + "work_item_code": "FP-05-01-04" + }, + { + "alternative_amount": null, + "amount": "0.19", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.3m 미만", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.3∼0.7m 이하", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.40", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.8∼1.1m 이하", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.57", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "1.2∼1.5m 이하", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.06", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.3m 미만", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.3∼0.7m 이하", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.13", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.8∼1.1m 이하", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.18", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0102", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1.2∼1.5m 이하", + "work_item_code": "FP-05-03-02" + }, + { + "alternative_amount": null, + "amount": "0.07", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.3m 미만", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.10", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.3∼0.7m 이하", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.15", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "0.8∼1.1m 이하", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.21", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "1.2∼1.5m 이하", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.3m 미만", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.3∼0.7m 이하", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "0.8∼1.1m 이하", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.07", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0103", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1.2∼1.5m 이하", + "work_item_code": "FP-05-03-03" + }, + { + "alternative_amount": null, + "amount": "0.47", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0114", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-09" + }, + { + "alternative_amount": null, + "amount": "1.33", + "amount_unit": "㏊", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0115", + "raw_row_index": 4, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-10" + }, + { + "alternative_amount": null, + "amount": "2E+1", + "amount_unit": "㏊", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0115", + "raw_row_index": 5, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-10" + }, + { + "alternative_amount": null, + "amount": "0.43", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0116", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-11" + }, + { + "alternative_amount": null, + "amount": "4.5", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0118", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "줄떼심기", + "work_item_code": "FP-05-13" + }, + { + "alternative_amount": null, + "amount": "6.0", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0118", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "띠떼심기", + "work_item_code": "FP-05-13" + }, + { + "alternative_amount": null, + "amount": "0.0328", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0119", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-14" + }, + { + "alternative_amount": null, + "amount": "0.0328", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0119", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-14" + }, + { + "alternative_amount": null, + "amount": "0.326", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0120", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-15" + }, + { + "alternative_amount": null, + "amount": "0.022", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0120", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-15" + }, + { + "alternative_amount": null, + "amount": "0.00055", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0125", + "raw_row_index": 6, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-18" + }, + { + "alternative_amount": null, + "amount": "0.00838", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0125", + "raw_row_index": 7, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-18" + }, + { + "alternative_amount": null, + "amount": "0.20", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0126", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-19-01" + }, + { + "alternative_amount": null, + "amount": "0.004666666666666666666666666667", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0127", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-19-02" + }, + { + "alternative_amount": null, + "amount": "0.34", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0129", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "채 취", + "work_item_code": "FP-05-21" + }, + { + "alternative_amount": null, + "amount": "0.26", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0129", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "운 반", + "work_item_code": "FP-05-21" + }, + { + "alternative_amount": null, + "amount": "0.47", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0129", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "붙이기", + "work_item_code": "FP-05-21" + }, + { + "alternative_amount": null, + "amount": "0.0099", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0130", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-22-02" + }, + { + "alternative_amount": null, + "amount": "0.0231", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0130", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-22-02" + }, + { + "alternative_amount": null, + "amount": "0.011", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0131", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-22-03" + }, + { + "alternative_amount": null, + "amount": "0.0084", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0133", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-23-02" + }, + { + "alternative_amount": null, + "amount": "0.0196", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0133", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-23-02" + }, + { + "alternative_amount": null, + "amount": "0.0033", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0134", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-23-03" + }, + { + "alternative_amount": null, + "amount": "0.0007", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0135", + "raw_row_index": 8, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-24-01" + }, + { + "alternative_amount": null, + "amount": "0.0004", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0135", + "raw_row_index": 9, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-24-01" + }, + { + "alternative_amount": null, + "amount": "0.0007", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0136", + "raw_row_index": 8, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-24-02" + }, + { + "alternative_amount": null, + "amount": "0.0004", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0136", + "raw_row_index": 9, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-24-02" + }, + { + "alternative_amount": null, + "amount": "0.002", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0139", + "raw_row_index": 1, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-25" + }, + { + "alternative_amount": null, + "amount": "0.0007", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0139", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-25" + }, + { + "alternative_amount": null, + "amount": "0.01", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0143", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-27" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0143", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-27" + }, + { + "alternative_amount": null, + "amount": "0.19", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0144", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-28-01" + }, + { + "alternative_amount": null, + "amount": "0.06", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0144", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-05-28-01" + }, + { + "alternative_amount": null, + "amount": "0.02222222222222222222222222222", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0145", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "alternative_amount": null, + "amount": "0.01111111111111111111111111111", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0145", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "alternative_amount": null, + "amount": "0.01538461538461538461538461538", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0145", + "raw_row_index": 1, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "alternative_amount": null, + "amount": "0.007692307692307692307692307692", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0145", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-05-28-02" + }, + { + "alternative_amount": null, + "amount": "3.00", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0163", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-05" + }, + { + "alternative_amount": null, + "amount": "2.00", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0163", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-05" + }, + { + "alternative_amount": null, + "amount": "0.29", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0165", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-07-01" + }, + { + "alternative_amount": null, + "amount": "0.09", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0165", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-07-01" + }, + { + "alternative_amount": null, + "amount": "0.3", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0166", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-07-02" + }, + { + "alternative_amount": null, + "amount": "0.8", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0166", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-07-02" + }, + { + "alternative_amount": null, + "amount": "0.00004", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0167", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-07-03" + }, + { + "alternative_amount": null, + "amount": "0.00014", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0167", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-07-03" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0168", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-06-08" + }, + { + "alternative_amount": null, + "amount": "0.8", + "amount_unit": "ha", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0225", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "소형헬기 (160ha당)", + "work_item_code": "FP-08-06-01" + }, + { + "alternative_amount": null, + "amount": "8.2", + "amount_unit": "ha", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0225", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "소형헬기 (160ha당)", + "work_item_code": "FP-08-06-01" + }, + { + "alternative_amount": null, + "amount": "2.1", + "amount_unit": "ha", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0225", + "raw_row_index": 7, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "대형헬기 (400ha당)", + "work_item_code": "FP-08-06-01" + }, + { + "alternative_amount": null, + "amount": "13.4", + "amount_unit": "ha", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0225", + "raw_row_index": 7, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "대형헬기 (400ha당)", + "work_item_code": "FP-08-06-01" + }, + { + "alternative_amount": null, + "amount": "1.05", + "amount_unit": "인", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0230", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "차량살포 (동력분무기)", + "work_item_code": "FP-08-06-03" + }, + { + "alternative_amount": null, + "amount": "2.00", + "amount_unit": "인", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0230", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "차량살포 (동력분무기)", + "work_item_code": "FP-08-06-03" + }, + { + "alternative_amount": null, + "amount": "0.75", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0236", + "raw_row_index": 1, + "resource_code": "1037", + "resource_kind": "labor", + "resource_name": "벌목부", + "resource_spec": "", + "variant": "1㎥", + "work_item_code": "FP-08-10" + }, + { + "alternative_amount": null, + "amount": "0.23", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0236", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "1㎥", + "work_item_code": "FP-08-10" + }, + { + "alternative_amount": null, + "amount": "1.50", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0236", + "raw_row_index": 4, + "resource_code": "1037", + "resource_kind": "labor", + "resource_name": "벌목부", + "resource_spec": "", + "variant": "2㎥", + "work_item_code": "FP-08-10" + }, + { + "alternative_amount": null, + "amount": "0.46", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0236", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "2㎥", + "work_item_code": "FP-08-10" + }, + { + "alternative_amount": null, + "amount": "0.16", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0239", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-03-01" + }, + { + "alternative_amount": null, + "amount": "0.041", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0243", + "raw_row_index": 4, + "resource_code": "1015", + "resource_kind": "labor", + "resource_name": "착암공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-05-01" + }, + { + "alternative_amount": null, + "amount": "0.103", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0243", + "raw_row_index": 5, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-05-01" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0251", + "raw_row_index": 0, + "resource_code": "1012", + "resource_kind": "labor", + "resource_name": "용접공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-08-01" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0251", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-08-01" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0256", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-11-01" + }, + { + "alternative_amount": null, + "amount": "2", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0256", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-11-01" + }, + { + "alternative_amount": null, + "amount": "0.23", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0258", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-12-01" + }, + { + "alternative_amount": null, + "amount": "1.6", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0259", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-12-02" + }, + { + "alternative_amount": null, + "amount": "0.8", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0259", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-12-02" + }, + { + "alternative_amount": null, + "amount": "2.8", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0260", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-12-03" + }, + { + "alternative_amount": null, + "amount": "1.266", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0260", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-12-03" + }, + { + "alternative_amount": null, + "amount": "0.23", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0261", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-01" + }, + { + "alternative_amount": null, + "amount": "0.31", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0262", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-02" + }, + { + "alternative_amount": null, + "amount": "0.39", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0263", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-03" + }, + { + "alternative_amount": null, + "amount": "0.345", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0264", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-04" + }, + { + "alternative_amount": null, + "amount": "0.465", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0265", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-05" + }, + { + "alternative_amount": null, + "amount": "0.585", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0266", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-06" + }, + { + "alternative_amount": null, + "amount": "1.6", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0267", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-07" + }, + { + "alternative_amount": null, + "amount": "0.8", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0267", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-07" + }, + { + "alternative_amount": null, + "amount": "1.8", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0268", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-08" + }, + { + "alternative_amount": null, + "amount": "0.9", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0268", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-08" + }, + { + "alternative_amount": null, + "amount": "2", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0269", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-09" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0269", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-09" + }, + { + "alternative_amount": null, + "amount": "1.2", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0270", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-10" + }, + { + "alternative_amount": null, + "amount": "1.35", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0271", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-11" + }, + { + "alternative_amount": null, + "amount": "1.5", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0272", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-12" + }, + { + "alternative_amount": null, + "amount": "2.8", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0273", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-13" + }, + { + "alternative_amount": null, + "amount": "1.266", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0273", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-13" + }, + { + "alternative_amount": null, + "amount": "3.5", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0274", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-14" + }, + { + "alternative_amount": null, + "amount": "1.566", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0274", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-14" + }, + { + "alternative_amount": null, + "amount": "4.2", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0275", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-15" + }, + { + "alternative_amount": null, + "amount": "1.866", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0275", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-15" + }, + { + "alternative_amount": null, + "amount": "4.2", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0276", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-16" + }, + { + "alternative_amount": null, + "amount": "1.899", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0276", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-16" + }, + { + "alternative_amount": null, + "amount": "5.25", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0277", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-17" + }, + { + "alternative_amount": null, + "amount": "2.349", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0277", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-17" + }, + { + "alternative_amount": null, + "amount": "6.3", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0278", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-18" + }, + { + "alternative_amount": null, + "amount": "2.799", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0278", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-13-18" + }, + { + "alternative_amount": null, + "amount": "0.1", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0279", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-14-01" + }, + { + "alternative_amount": null, + "amount": "0.017", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0289", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-19-01" + }, + { + "alternative_amount": null, + "amount": "0.019", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0290", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-19-02" + }, + { + "alternative_amount": null, + "amount": "0.045", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0290", + "raw_row_index": 1, + "resource_code": "0201-0070", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.7", + "variant": "", + "work_item_code": "FP-09-19-02" + }, + { + "alternative_amount": null, + "amount": "0.0328", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0291", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-19-03" + }, + { + "alternative_amount": null, + "amount": "0.063", + "amount_unit": "주", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0292", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-20-01" + }, + { + "alternative_amount": null, + "amount": "0.042", + "amount_unit": "주", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0292", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-20-01" + }, + { + "alternative_amount": null, + "amount": "0.33", + "amount_unit": "주", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0292", + "raw_row_index": 2, + "resource_code": "0201-0070", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.7", + "variant": "", + "work_item_code": "FP-09-20-01" + }, + { + "alternative_amount": null, + "amount": "0.027", + "amount_unit": "주", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0293", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-20-02" + }, + { + "alternative_amount": null, + "amount": "0.36", + "amount_unit": "주", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0293", + "raw_row_index": 1, + "resource_code": "0201-0070", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.7", + "variant": "", + "work_item_code": "FP-09-20-02" + }, + { + "alternative_amount": null, + "amount": "0.80", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0294", + "raw_row_index": 0, + "resource_code": "0201-0020", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.2", + "variant": "", + "work_item_code": "FP-09-21" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0294", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-21" + }, + { + "alternative_amount": null, + "amount": "0.46", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0294", + "raw_row_index": 2, + "resource_code": "0201-0070", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.7", + "variant": "", + "work_item_code": "FP-09-21" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0294", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-09-21" + }, + { + "alternative_amount": null, + "amount": "0.5", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0303", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-06-02" + }, + { + "alternative_amount": null, + "amount": "0.0035", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0305", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-01" + }, + { + "alternative_amount": null, + "amount": "0.0035", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0305", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-01" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0306", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-02" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0306", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-02" + }, + { + "alternative_amount": null, + "amount": "0.06", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0306", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-02" + }, + { + "alternative_amount": null, + "amount": "0.01", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0307", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-03" + }, + { + "alternative_amount": null, + "amount": "0.01", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0307", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-03" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0307", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-03" + }, + { + "alternative_amount": null, + "amount": "2.0", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0310", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-04" + }, + { + "alternative_amount": null, + "amount": "2", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0312", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-07-04" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0315", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-08-03" + }, + { + "alternative_amount": null, + "amount": "2.0", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0317", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-10-01" + }, + { + "alternative_amount": null, + "amount": "2.0", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0318", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-10-10-02" + }, + { + "alternative_amount": null, + "amount": "0.16", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0326", + "raw_row_index": 0, + "resource_code": "1023", + "resource_kind": "labor", + "resource_name": "건축목공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-11-02" + }, + { + "alternative_amount": null, + "amount": "0.14", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0326", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-11-02" + }, + { + "alternative_amount": null, + "amount": "0.21", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0327", + "raw_row_index": 0, + "resource_code": "1023", + "resource_kind": "labor", + "resource_name": "건축목공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-11-03" + }, + { + "alternative_amount": null, + "amount": "0.19", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0327", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-11-03" + }, + { + "alternative_amount": null, + "amount": "0.12", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0329", + "raw_row_index": 0, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "무근구조물", + "work_item_code": "FP-12-01-01" + }, + { + "alternative_amount": null, + "amount": "0.15", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0329", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "무근구조물", + "work_item_code": "FP-12-01-01" + }, + { + "alternative_amount": null, + "amount": "0.14", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0329", + "raw_row_index": 1, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "철근구조물", + "work_item_code": "FP-12-01-01" + }, + { + "alternative_amount": null, + "amount": "0.16", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0329", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "철근구조물", + "work_item_code": "FP-12-01-01" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0329", + "raw_row_index": 2, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "소형구조물", + "work_item_code": "FP-12-01-01" + }, + { + "alternative_amount": null, + "amount": "0.30", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0329", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "소형구조물", + "work_item_code": "FP-12-01-01" + }, + { + "alternative_amount": null, + "amount": "0.15", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0330", + "raw_row_index": 0, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "무근구조물", + "work_item_code": "FP-12-01-02" + }, + { + "alternative_amount": null, + "amount": "0.46", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0330", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "무근구조물", + "work_item_code": "FP-12-01-02" + }, + { + "alternative_amount": null, + "amount": "0.17", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0330", + "raw_row_index": 1, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "철근구조물", + "work_item_code": "FP-12-01-02" + }, + { + "alternative_amount": null, + "amount": "0.68", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0330", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "철근구조물", + "work_item_code": "FP-12-01-02" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0330", + "raw_row_index": 2, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "소형구조물", + "work_item_code": "FP-12-01-02" + }, + { + "alternative_amount": null, + "amount": "0.94", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0330", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "소형구조물", + "work_item_code": "FP-12-01-02" + }, + { + "alternative_amount": null, + "amount": "0.85", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0331", + "raw_row_index": 0, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "무근구조물", + "work_item_code": "FP-12-01-03" + }, + { + "alternative_amount": null, + "amount": "0.82", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0331", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "무근구조물", + "work_item_code": "FP-12-01-03" + }, + { + "alternative_amount": null, + "amount": "0.87", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0331", + "raw_row_index": 1, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "철근구조물", + "work_item_code": "FP-12-01-03" + }, + { + "alternative_amount": null, + "amount": "0.99", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0331", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "철근구조물", + "work_item_code": "FP-12-01-03" + }, + { + "alternative_amount": null, + "amount": "1.29", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0331", + "raw_row_index": 2, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "소형구조물", + "work_item_code": "FP-12-01-03" + }, + { + "alternative_amount": null, + "amount": "1.36", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0331", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "소형구조물", + "work_item_code": "FP-12-01-03" + }, + { + "alternative_amount": null, + "amount": "0.34", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0334", + "raw_row_index": 0, + "resource_code": "1027", + "resource_kind": "labor", + "resource_name": "미장공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-02" + }, + { + "alternative_amount": null, + "amount": "0.009090909090909090909090909091", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0362", + "raw_row_index": 0, + "resource_code": "1006", + "resource_kind": "labor", + "resource_name": "비계공", + "resource_spec": "", + "variant": "설 치", + "work_item_code": "FP-12-02" + }, + { + "alternative_amount": null, + "amount": "0.006060606060606060606060606061", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0362", + "raw_row_index": 1, + "resource_code": "1006", + "resource_kind": "labor", + "resource_name": "비계공", + "resource_spec": "", + "variant": "철 거", + "work_item_code": "FP-12-02" + }, + { + "alternative_amount": null, + "amount": "1.07", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 1, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.35", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "1.69", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 1, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.69", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "1.24", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 2, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.45", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "1.84", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 2, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.75", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "1.51", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 3, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.50", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "1.92", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 3, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.80", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "1.69", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 4, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "매우복잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.60", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "매우복잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "2.14", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 4, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "매우복잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.86", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0335", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "매우복잡", + "work_item_code": "FP-12-03" + }, + { + "alternative_amount": null, + "amount": "0.07", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0337", + "raw_row_index": 1, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-05" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0337", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-05" + }, + { + "alternative_amount": null, + "amount": "0.002857142857142857142857142857", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0340", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-07-02" + }, + { + "alternative_amount": null, + "amount": "0.004285714285714285714285714286", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0340", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-07-02" + }, + { + "alternative_amount": null, + "amount": "0.0055", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0345", + "raw_row_index": 1, + "resource_code": "1039", + "resource_kind": "labor", + "resource_name": "배관공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-10" + }, + { + "alternative_amount": null, + "amount": "0.0055", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0345", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-10" + }, + { + "alternative_amount": null, + "amount": "0.003", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0345", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-10" + }, + { + "alternative_amount": null, + "amount": "0.13", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0345", + "raw_row_index": 7, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-10" + }, + { + "alternative_amount": null, + "amount": "0.12", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0348", + "raw_row_index": 1, + "resource_code": "1050", + "resource_kind": "labor", + "resource_name": "일반기계운전사", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-11-02" + }, + { + "alternative_amount": null, + "amount": "1.23", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0348", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-11-02" + }, + { + "alternative_amount": null, + "amount": "4", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0355", + "raw_row_index": 1, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "무근콘크리트", + "work_item_code": "FP-12-17-01" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0355", + "raw_row_index": 1, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "철근콘크리트", + "work_item_code": "FP-12-17-01" + }, + { + "alternative_amount": null, + "amount": "2", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0355", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "무근콘크리트", + "work_item_code": "FP-12-17-01" + }, + { + "alternative_amount": null, + "amount": "2", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0355", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "철근콘크리트", + "work_item_code": "FP-12-17-01" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0355", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "무근콘크리트", + "work_item_code": "FP-12-17-01" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0355", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "철근콘크리트", + "work_item_code": "FP-12-17-01" + }, + { + "alternative_amount": null, + "amount": "0.07", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0363", + "raw_row_index": 1, + "resource_code": "1013", + "resource_kind": "labor", + "resource_name": "콘크리트공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-17-02" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0363", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-17-02" + }, + { + "alternative_amount": null, + "amount": "4.2", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0365", + "raw_row_index": 1, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-18" + }, + { + "alternative_amount": null, + "amount": "2.4", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0365", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-18" + }, + { + "alternative_amount": null, + "amount": "0.10", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0366", + "raw_row_index": 5, + "resource_code": "1006", + "resource_kind": "labor", + "resource_name": "비계공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-19" + }, + { + "alternative_amount": null, + "amount": "0.07", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0367", + "raw_row_index": 3, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-20" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0367", + "raw_row_index": 4, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-20" + }, + { + "alternative_amount": null, + "amount": "0.034", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0368", + "raw_row_index": 1, + "resource_code": "1026", + "resource_kind": "labor", + "resource_name": "방수공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-21" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0368", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-21" + }, + { + "alternative_amount": null, + "amount": "0.003", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0370", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-23" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0372", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "뒷채움 자재비 및 운반비 별산", + "work_item_code": "FP-12-24-02" + }, + { + "alternative_amount": null, + "amount": "0.06", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0373", + "raw_row_index": 0, + "resource_code": "1017", + "resource_kind": "labor", + "resource_name": "할석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-25" + }, + { + "alternative_amount": null, + "amount": "0.6", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0373", + "raw_row_index": 6, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-25" + }, + { + "alternative_amount": null, + "amount": "1", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0374", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-26" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0371", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "뒷채움 자재비 및 운반비 별산", + "work_item_code": "FP-12-24-01" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0376", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-27-02" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0377", + "raw_row_index": 1, + "resource_code": "1026", + "resource_kind": "labor", + "resource_name": "방수공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-27-03" + }, + { + "alternative_amount": null, + "amount": "0.12", + "amount_unit": "개소", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0378", + "raw_row_index": 2, + "resource_code": "1027", + "resource_kind": "labor", + "resource_name": "미장공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-28" + }, + { + "alternative_amount": null, + "amount": "0.004", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0384", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-33" + }, + { + "alternative_amount": null, + "amount": "3.8", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0387", + "raw_row_index": 1, + "resource_code": "1008", + "resource_kind": "labor", + "resource_name": "철근공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-34-03" + }, + { + "alternative_amount": null, + "amount": "2.2", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0387", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-34-03" + }, + { + "alternative_amount": null, + "amount": "0.14", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0389", + "raw_row_index": 1, + "resource_code": "1027", + "resource_kind": "labor", + "resource_name": "미장공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-35" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0389", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-12-35" + }, + { + "alternative_amount": null, + "amount": "0.16", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0395", + "raw_row_index": 0, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-38-03" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0395", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "복 잡", + "work_item_code": "FP-12-38-03" + }, + { + "alternative_amount": null, + "amount": "0.1142857142857142857142857143", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0395", + "raw_row_index": 1, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-38-03" + }, + { + "alternative_amount": null, + "amount": "0.02857142857142857142857142857", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0395", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "보 통", + "work_item_code": "FP-12-38-03" + }, + { + "alternative_amount": null, + "amount": "0.1", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0395", + "raw_row_index": 2, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-38-03" + }, + { + "alternative_amount": null, + "amount": "0.025", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0395", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "간 단", + "work_item_code": "FP-12-38-03" + }, + { + "alternative_amount": null, + "amount": "0.5", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0397", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-02-01" + }, + { + "alternative_amount": null, + "amount": "0.17", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0398", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-02-02" + }, + { + "alternative_amount": null, + "amount": "0.026", + "amount_unit": "개", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0399", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-02-03" + }, + { + "alternative_amount": null, + "amount": "0.11", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.10", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.09", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.22", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "35cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.22", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "35cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.21", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "55cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.21", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "55cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.20", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "75cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.20", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0418", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "75cm 이하", + "work_item_code": "FP-13-03" + }, + { + "alternative_amount": null, + "amount": "0.5", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0402", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "두 께 3㎝", + "work_item_code": "FP-13-03-01" + }, + { + "alternative_amount": null, + "amount": "0.4", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0402", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "두 께 6㎝", + "work_item_code": "FP-13-03-01" + }, + { + "alternative_amount": null, + "amount": "0.5", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0402", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "자갈 기초다짐 지름 1~3㎝", + "work_item_code": "FP-13-03-01" + }, + { + "alternative_amount": null, + "amount": "0.10", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.09", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.39", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.39", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.37", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.37", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.35", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.35", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0407", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-02" + }, + { + "alternative_amount": null, + "amount": "0.09", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.07", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.31", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.31", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "35cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.30", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.30", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "55cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.28", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.28", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0414", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "75cm 이하", + "work_item_code": "FP-13-04-05" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0415", + "raw_row_index": 0, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "25㎝", + "work_item_code": "FP-13-04-06" + }, + { + "alternative_amount": null, + "amount": "0.08", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0415", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "30㎝", + "work_item_code": "FP-13-04-06" + }, + { + "alternative_amount": null, + "amount": "0.09", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0415", + "raw_row_index": 2, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "35㎝", + "work_item_code": "FP-13-04-06" + }, + { + "alternative_amount": null, + "amount": "0.13", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.04", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "35cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.12", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.03", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "55cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.11", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.02", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "75cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.25", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "35cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.25", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "35cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "55cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "55cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.22", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 2, + "resource_code": "0201-0060", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.6", + "variant": "75cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.22", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0417", + "raw_row_index": 2, + "resource_code": "7206-0070", + "resource_kind": "machine", + "resource_name": "부착용 집게", + "resource_spec": "0.6∼0.8", + "variant": "75cm 이하", + "work_item_code": "FP-13-05-02" + }, + { + "alternative_amount": null, + "amount": "0.083", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.075", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.068", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.104", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.108", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.111", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.104", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.108", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.111", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0419", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-01" + }, + { + "alternative_amount": null, + "amount": "0.083", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.075", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.068", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.13", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.135", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.139", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.13", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.135", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.139", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.48", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.44", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.392", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0420", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-06-02" + }, + { + "alternative_amount": null, + "amount": "0.119", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.107", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.097", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.186", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.193", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.199", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.186", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.193", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.199", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.686", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.629", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.56", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0421", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-06-03" + }, + { + "alternative_amount": null, + "amount": "0.058", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.053", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.048", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.058", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.053", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.048", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.083", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.089", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.094", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 40㎝이상 ~60㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.216", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 60㎝이상 ~80㎝미만", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.192", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0422", + "raw_row_index": 4, + "resource_code": "0201-0080", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.8", + "variant": "직경 80㎝이상 ~100㎝이하", + "work_item_code": "FP-13-07-01" + }, + { + "alternative_amount": null, + "amount": "0.058", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.053", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.048", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 1, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.101", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.102", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.102", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.101", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 40㎝이상 ∼60㎝미만", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.102", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 60㎝이상 ∼80㎝미만", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.102", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0423", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 80㎝이상 ∼100㎝이하", + "work_item_code": "FP-13-07-02" + }, + { + "alternative_amount": null, + "amount": "0.3", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0424", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-08" + }, + { + "alternative_amount": null, + "amount": "0.084", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0425", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-09" + }, + { + "alternative_amount": null, + "amount": "0.251", + "amount_unit": "ton", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0425", + "raw_row_index": 1, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-09" + }, + { + "alternative_amount": null, + "amount": "0.009", + "amount_unit": "개", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0426", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "직경 15㎝ 길이 4m", + "work_item_code": "FP-13-10-01" + }, + { + "alternative_amount": null, + "amount": "0.022", + "amount_unit": "개", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0426", + "raw_row_index": 0, + "resource_code": "1007", + "resource_kind": "labor", + "resource_name": "형틀목공", + "resource_spec": "", + "variant": "직경 15㎝ 길이 4m", + "work_item_code": "FP-13-10-01" + }, + { + "alternative_amount": null, + "amount": "0.035", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0432", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-01" + }, + { + "alternative_amount": null, + "amount": "0.015", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0432", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-01" + }, + { + "alternative_amount": null, + "amount": "0.037", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0432", + "raw_row_index": 2, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-01" + }, + { + "alternative_amount": null, + "amount": "0.013", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0434", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-02" + }, + { + "alternative_amount": null, + "amount": "0.005", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0434", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-02" + }, + { + "alternative_amount": null, + "amount": "0.039", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0434", + "raw_row_index": 2, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-02" + }, + { + "alternative_amount": null, + "amount": "0.004", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0435", + "raw_row_index": 0, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-03" + }, + { + "alternative_amount": null, + "amount": "0.007", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0435", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-03" + }, + { + "alternative_amount": null, + "amount": "0.072", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0436", + "raw_row_index": 3, + "resource_code": "1033", + "resource_kind": "labor", + "resource_name": "석공", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-04" + }, + { + "alternative_amount": null, + "amount": "0.053", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0436", + "raw_row_index": 4, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-04" + }, + { + "alternative_amount": null, + "amount": "0.013", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0436", + "raw_row_index": 5, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-11-04" + }, + { + "alternative_amount": null, + "amount": "0.101", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0436", + "raw_row_index": 6, + "resource_code": "0201-0070", + "resource_kind": "machine", + "resource_name": "굴착기(무한궤도)", + "resource_spec": "0.7", + "variant": "", + "work_item_code": "FP-13-11-04" + }, + { + "alternative_amount": null, + "amount": "0.0141", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0438", + "raw_row_index": 0, + "resource_code": "1001", + "resource_kind": "labor", + "resource_name": "작업반장", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-12-02" + }, + { + "alternative_amount": null, + "amount": "0.0381", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0438", + "raw_row_index": 1, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-12-02" + }, + { + "alternative_amount": null, + "amount": "0.0099", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0438", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-12-02" + }, + { + "alternative_amount": null, + "amount": "7.274", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0440", + "raw_row_index": 1, + "resource_code": "1023", + "resource_kind": "labor", + "resource_name": "건축목공", + "resource_spec": "", + "variant": "중", + "work_item_code": "FP-13-13-01" + }, + { + "alternative_amount": null, + "amount": "0.786", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0440", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "중", + "work_item_code": "FP-13-13-01" + }, + { + "alternative_amount": null, + "amount": "16.975", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0440", + "raw_row_index": 5, + "resource_code": "1023", + "resource_kind": "labor", + "resource_name": "건축목공", + "resource_spec": "", + "variant": "상등구조", + "work_item_code": "FP-13-13-01" + }, + { + "alternative_amount": null, + "amount": "1.848", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0440", + "raw_row_index": 5, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "상등구조", + "work_item_code": "FP-13-13-01" + }, + { + "alternative_amount": null, + "amount": "0.2", + "amount_unit": "㎥", + "group_ratio_pct": "10", + "pum_form": "requirement", + "pum_table_id": "F0441", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-13-02" + }, + { + "alternative_amount": null, + "amount": "2.80", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0443", + "raw_row_index": 0, + "resource_code": "1023", + "resource_kind": "labor", + "resource_name": "건축목공", + "resource_spec": "", + "variant": "간 단 구 조", + "work_item_code": "FP-13-13-02" + }, + { + "alternative_amount": null, + "amount": "0.80", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0443", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "간 단 구 조", + "work_item_code": "FP-13-13-02" + }, + { + "alternative_amount": null, + "amount": "6.31", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0443", + "raw_row_index": 1, + "resource_code": "1023", + "resource_kind": "labor", + "resource_name": "건축목공", + "resource_spec": "", + "variant": "보 통 구 조", + "work_item_code": "FP-13-13-02" + }, + { + "alternative_amount": null, + "amount": "1.31", + "amount_unit": "㎥", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0443", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "보 통 구 조", + "work_item_code": "FP-13-13-02" + }, + { + "alternative_amount": null, + "amount": "0.030", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0444", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "단 끊 기", + "work_item_code": "FP-13-14" + }, + { + "alternative_amount": null, + "amount": "0.035", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0444", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "흙채우기(마대채우기)", + "work_item_code": "FP-13-14" + }, + { + "alternative_amount": null, + "amount": "0.025", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0444", + "raw_row_index": 2, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "마대쌓기", + "work_item_code": "FP-13-14" + }, + { + "alternative_amount": null, + "amount": "0.0027", + "amount_unit": "m", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0445", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-13-15-01" + }, + { + "alternative_amount": null, + "amount": "0.09", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "사 면", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.10", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "연약지반", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.16", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "사 면", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.24", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 2, + "resource_code": "1003", + "resource_kind": "labor", + "resource_name": "특별인부", + "resource_spec": "", + "variant": "연약지반", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "사 면", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "연약지반", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.12", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "사 면", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.12", + "amount_unit": "㎡", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0448", + "raw_row_index": 3, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "연약지반", + "work_item_code": "FP-13-16-01" + }, + { + "alternative_amount": null, + "amount": "0.02222222222222222222222222222", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0449", + "raw_row_index": 0, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "alternative_amount": null, + "amount": "0.01111111111111111111111111111", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0449", + "raw_row_index": 0, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 1.5m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "alternative_amount": null, + "amount": "0.01538461538461538461538461538", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0449", + "raw_row_index": 1, + "resource_code": "1038", + "resource_kind": "labor", + "resource_name": "조경공", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "alternative_amount": null, + "amount": "0.007692307692307692307692307692", + "amount_unit": "", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0449", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "폭 2.0m 이하", + "work_item_code": "FP-13-16-02" + }, + { + "alternative_amount": null, + "amount": "0.05", + "amount_unit": "본", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0450", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-14-01" + }, + { + "alternative_amount": null, + "amount": "0.066", + "amount_unit": "본", + "group_ratio_pct": null, + "pum_form": "requirement", + "pum_table_id": "F0451", + "raw_row_index": 1, + "resource_code": "1002", + "resource_kind": "labor", + "resource_name": "보통인부", + "resource_spec": "", + "variant": "", + "work_item_code": "FP-14-02" + } + ], + "schema_version": "1.0", + "source_dataset_version": { + "dataset_id": "pum_forest", + "effective_date": "2026-01-01", + "file": "pum_forest_2026.json", + "sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd" + }, + "source_master_file": { + "file": "work_item_master_2026-01-01.json", + "sha256": "3752af1e0328a2faf4947268e449e0bc602a0738cc6a29a78ed71d343cf2e583" + }, + "stats": { + "rows": 418, + "skipped_forms": { + "coefficient": 19, + "reference": 64, + "undetermined": 75 + }, + "unmatched": 497 + } +} \ No newline at end of file diff --git a/resources/data_cost_resource_axis/unmatched_2026-01-01.json b/resources/data_cost_resource_axis/unmatched_2026-01-01.json new file mode 100644 index 00000000..6b216ec5 --- /dev/null +++ b/resources/data_cost_resource_axis/unmatched_2026-01-01.json @@ -0,0 +1,2989 @@ +{ + "effective_date": "2026-01-01", + "note": "못 맞춘 자원 이름. 빈칸으로 두지 않고 여기 모은다. 기계·자재 카탈로그가 아직 없어 그 계열은 전부 여기로 온다.", + "rows": [ + { + "cell": "구분 | 조 건 | 적용시공량", + "pum_table_id": "F0038", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「1」을 못 풀었습니다", + "work_item_code": "FP-01-04-26" + }, + { + "cell": "보통휘발유 (주연료)", + "pum_table_id": "F0042", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-01" + }, + { + "cell": "체인오일 (일반오일)", + "pum_table_id": "F0042", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-01" + }, + { + "cell": "체인오일 (친환경오일)", + "pum_table_id": "F0042", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-01" + }, + { + "cell": "예취기(휘발유)", + "pum_table_id": "F0043", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-01" + }, + { + "cell": "천공기 (휘발유)", + "pum_table_id": "F0046", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-01" + }, + { + "cell": "페인트", + "pum_table_id": "F0049", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-04" + }, + { + "cell": "마킹테이프", + "pum_table_id": "F0049", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-04" + }, + { + "cell": "페인트", + "pum_table_id": "F0050", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-04" + }, + { + "cell": "마킹테이프", + "pum_table_id": "F0051", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-04" + }, + { + "cell": "페인트", + "pum_table_id": "F0052", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-04" + }, + { + "cell": "농약 (Fluroxypyr -meptyl + Triclypyr-TEA 미탁제)", + "pum_table_id": "F0053", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-05" + }, + { + "cell": "농약 (글리포세이트)", + "pum_table_id": "F0055", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-05" + }, + { + "cell": "친환경 비닐랩", + "pum_table_id": "F0055", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-05" + }, + { + "cell": "천공테이프", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "흰색 페인트 (친환경성)", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "표식라벨", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "천공기날", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "약재주입기", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "방제복", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "약제배낭", + "pum_table_id": "F0056", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "천공기날", + "pum_table_id": "F0056", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "약재주입기", + "pum_table_id": "F0056", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "방제복", + "pum_table_id": "F0056", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "약제배낭", + "pum_table_id": "F0056", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-06" + }, + { + "cell": "벌근훈증약제", + "pum_table_id": "F0057", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-07" + }, + { + "cell": "벌근피복제", + "pum_table_id": "F0057", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-02-01-07" + }, + { + "cell": "휘발유 (양수기)", + "pum_table_id": "F0058", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-08" + }, + { + "cell": "깃 발", + "pum_table_id": "F0058", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-08" + }, + { + "cell": "휘발유 (연료)", + "pum_table_id": "F0059", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-09" + }, + { + "cell": "잡품", + "pum_table_id": "F0059", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-09" + }, + { + "cell": "경유(차량살포)", + "pum_table_id": "F0061", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-02-01-11" + }, + { + "cell": "작업로 예정선 선정 및 표식", + "pum_table_id": "F0076", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-03-02" + }, + { + "cell": "작업로 예정선 선정 및 표식", + "pum_table_id": "F0076", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-02" + }, + { + "cell": "소작업로", + "pum_table_id": "F0077", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-03-03" + }, + { + "cell": "대작업로", + "pum_table_id": "F0077", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-03-03" + }, + { + "cell": "소작업로", + "pum_table_id": "F0077", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-03" + }, + { + "cell": "대작업로", + "pum_table_id": "F0077", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-03" + }, + { + "cell": "노면굴기", + "pum_table_id": "F0078", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-03-04-03" + }, + { + "cell": "모든 벌채산물 임내존치지역", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "휴경지 등 정리", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "관목지 등 정리", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "산불 등 피해지 정리", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "불량림 정리", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "벌채부산물 임내존치지역", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "임업기계장비 이용 정리", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "벌채와 동시정리지역", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "모든 벌채부산물 반출", + "pum_table_id": "F0079", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-05" + }, + { + "cell": "임내 정리", + "pum_table_id": "F0080", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-03-06" + }, + { + "cell": "임내 정리", + "pum_table_id": "F0080", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-03-06" + }, + { + "cell": "단목", + "pum_table_id": "F0083", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-01-01" + }, + { + "cell": "전간목", + "pum_table_id": "F0083", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-01-01" + }, + { + "cell": "전목", + "pum_table_id": "F0083", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-01-01" + }, + { + "cell": "10㎝이하", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "12~14㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "16~18㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "20~22㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "24~26㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "28~30㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "32~34㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "36~38㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "40~42㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "44~46㎝", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "48㎝ 이상", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "작업보조", + "pum_table_id": "F0086", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "작업보조", + "pum_table_id": "F0086", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-04-02-01" + }, + { + "cell": "굴착기+부착용집게", + "pum_table_id": "F0087", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-02" + }, + { + "cell": "비고", + "pum_table_id": "F0087", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-04-02-02" + }, + { + "cell": "비 고", + "pum_table_id": "F0093", + "reason": "숫자 칸 0 개가 자원 열 2 개와 안 맞아 버렸습니다(자리 밀림 방지).", + "work_item_code": "FP-05-01-02" + }, + { + "cell": "지게", + "pum_table_id": "F0097", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-01-05" + }, + { + "cell": "리어카", + "pum_table_id": "F0097", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-01-05" + }, + { + "cell": "우마차", + "pum_table_id": "F0097", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-01-05" + }, + { + "cell": "소묘식재", + "pum_table_id": "F0099", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-03-01" + }, + { + "cell": "중묘식재", + "pum_table_id": "F0099", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-03-01" + }, + { + "cell": "대묘식재", + "pum_table_id": "F0099", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-03-01" + }, + { + "cell": "특별인부(인)", + "pum_table_id": "F0104", + "reason": "값 묶음 0 개가 갈래 2 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-05-03-04" + }, + { + "cell": "파종상 만들기", + "pum_table_id": "F0109", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-04" + }, + { + "cell": "파종상에 점파", + "pum_table_id": "F0109", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-04" + }, + { + "cell": "파종상 없이", + "pum_table_id": "F0109", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-04" + }, + { + "cell": "점 파", + "pum_table_id": "F0109", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-04" + }, + { + "cell": "지면긁기작업", + "pum_table_id": "F0110", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-05" + }, + { + "cell": "폭 80cm × 열간거리 2m (전면적의 40%)", + "pum_table_id": "F0110", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-05" + }, + { + "cell": "맹아근주 정리작업", + "pum_table_id": "F0111", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-06" + }, + { + "cell": "움싹본수조절", + "pum_table_id": "F0112", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-07" + }, + { + "cell": "치수이식", + "pum_table_id": "F0112", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-07" + }, + { + "cell": "큰나무 식재", + "pum_table_id": "F0113", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-08" + }, + { + "cell": "묘목", + "pum_table_id": "F0115", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-10" + }, + { + "cell": "운반비", + "pum_table_id": "F0115", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-10" + }, + { + "cell": "평떼", + "pum_table_id": "F0117", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-12" + }, + { + "cell": "새채집", + "pum_table_id": "F0119", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-14" + }, + { + "cell": "요소", + "pum_table_id": "F0119", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-14" + }, + { + "cell": "인산", + "pum_table_id": "F0119", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-14" + }, + { + "cell": "새운반", + "pum_table_id": "F0119", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-14" + }, + { + "cell": "말뚝", + "pum_table_id": "F0120", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-15" + }, + { + "cell": "절 취", + "pum_table_id": "F0121", + "reason": "숫자 칸 3 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "수평잡기 및 단정리", + "pum_table_id": "F0121", + "reason": "숫자 칸 3 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "잡석 및 뿌리 정리", + "pum_table_id": "F0121", + "reason": "숫자 칸 3 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "절·성토면 고르기", + "pum_table_id": "F0121", + "reason": "숫자 칸 3 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "절 취", + "pum_table_id": "F0121", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "수평잡기 및 단정리", + "pum_table_id": "F0121", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "잡석 및 뿌리 정리", + "pum_table_id": "F0121", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "절·성토면 고르기", + "pum_table_id": "F0121", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "합계", + "pum_table_id": "F0121", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "절 취", + "pum_table_id": "F0121", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "수평잡기 및 단정리", + "pum_table_id": "F0121", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "잡석 및 뿌리 정리", + "pum_table_id": "F0121", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "절·성토면 고르기", + "pum_table_id": "F0121", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-16-01" + }, + { + "cell": "단끊기", + "pum_table_id": "F0122", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-16-02" + }, + { + "cell": "단끊기", + "pum_table_id": "F0123", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-17-01" + }, + { + "cell": "단끊기", + "pum_table_id": "F0124", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-17-02" + }, + { + "cell": "종자", + "pum_table_id": "F0125", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-18" + }, + { + "cell": "비료", + "pum_table_id": "F0125", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-18" + }, + { + "cell": "비토", + "pum_table_id": "F0125", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-18" + }, + { + "cell": "객토", + "pum_table_id": "F0125", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-18" + }, + { + "cell": "골파기", + "pum_table_id": "F0125", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-18" + }, + { + "cell": "씨덮기", + "pum_table_id": "F0125", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-18" + }, + { + "cell": "표토채취 | 보통인부 | 0.2인", + "pum_table_id": "F0128", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-20" + }, + { + "cell": "굴착기(무한궤도, 0.7㎥) | 0.1시간 | ", + "pum_table_id": "F0128", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-20" + }, + { + "cell": "표토붙이기 | 보통인부 | 0.2인", + "pum_table_id": "F0128", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-20" + }, + { + "cell": "굴착기(무한궤도, 0.7㎥) | 0.1시간 | ", + "pum_table_id": "F0128", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-05-20" + }, + { + "cell": "구 분 | 규 격 | 단위 | 수량 | 시공량(㎡)", + "pum_table_id": "F0132", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「트럭」을 못 풀었습니다", + "work_item_code": "FP-05-22-04" + }, + { + "cell": "종 자", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "비 료", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "피 복 제", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "침식안정제", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "색 소", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "종자살포기", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "트 럭", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "물 탱 크", + "pum_table_id": "F0135", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-01" + }, + { + "cell": "종 자", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "비 료", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "피 복 제", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "침식안정제", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "색 소", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "종자살포기", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "트 럭", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "물 탱 크", + "pum_table_id": "F0136", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-24-02" + }, + { + "cell": "거 적", + "pum_table_id": "F0139", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-25" + }, + { + "cell": "표시봉 설치", + "pum_table_id": "F0147", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-30" + }, + { + "cell": "지주목 설치", + "pum_table_id": "F0148", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-31" + }, + { + "cell": "대 절", + "pum_table_id": "F0149", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-32" + }, + { + "cell": "소묘, 중묘", + "pum_table_id": "F0150", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-33" + }, + { + "cell": "대 묘", + "pum_table_id": "F0150", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-33" + }, + { + "cell": "소 묘", + "pum_table_id": "F0151", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-34" + }, + { + "cell": "중 묘", + "pum_table_id": "F0151", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-34" + }, + { + "cell": "대 묘", + "pum_table_id": "F0151", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-05-34" + }, + { + "cell": "인공림(10년이하 조림지)", + "pum_table_id": "F0153", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-01" + }, + { + "cell": "인공림(10년초과 성림지)", + "pum_table_id": "F0153", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-01" + }, + { + "cell": "천연림", + "pum_table_id": "F0153", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-01" + }, + { + "cell": "묘목찾기", + "pum_table_id": "F0155", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-02-02" + }, + { + "cell": "줄 베 기", + "pum_table_id": "F0155", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-02-02" + }, + { + "cell": "묘목찾기", + "pum_table_id": "F0156", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-02-03" + }, + { + "cell": "모두베기", + "pum_table_id": "F0156", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-02-03" + }, + { + "cell": "조림 2년차", + "pum_table_id": "F0156", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-02-03" + }, + { + "cell": "조림 3년차 이상", + "pum_table_id": "F0156", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-02-03" + }, + { + "cell": "괭이, 도끼 등", + "pum_table_id": "F0157", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-03" + }, + { + "cell": "약제살포", + "pum_table_id": "F0159", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-02" + }, + { + "cell": "작업보조", + "pum_table_id": "F0159", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-02" + }, + { + "cell": "소금 처리", + "pum_table_id": "F0160", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-03" + }, + { + "cell": "덩굴 제거지점 표시", + "pum_table_id": "F0160", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-03" + }, + { + "cell": "뿌리 고살", + "pum_table_id": "F0161", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-04" + }, + { + "cell": "덩굴 제거지점 표시", + "pum_table_id": "F0161", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-04-04" + }, + { + "cell": "유령림 단계", + "pum_table_id": "F0163", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-05" + }, + { + "cell": "트 럭(2.5t)", + "pum_table_id": "F0167", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-07-03" + }, + { + "cell": "우 드 칩", + "pum_table_id": "F0168", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-08" + }, + { + "cell": "소 운 반", + "pum_table_id": "F0168", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-06-08" + }, + { + "cell": "병해충방제", + "pum_table_id": "F0172", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-01-02" + }, + { + "cell": "병해충방제", + "pum_table_id": "F0172", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-01-02" + }, + { + "cell": "병해충방제", + "pum_table_id": "F0172", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-01-02" + }, + { + "cell": "병해충방제", + "pum_table_id": "F0172", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-01-02" + }, + { + "cell": "병해충방제", + "pum_table_id": "F0172", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-01-02" + }, + { + "cell": "0.1", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.2", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.3", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.4", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.5", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.6", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.7", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.8", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "0.9", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "1.0", + "pum_table_id": "F0175", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-01" + }, + { + "cell": "집재량", + "pum_table_id": "F0176", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-04-02" + }, + { + "cell": "집재량", + "pum_table_id": "F0176", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-04-02" + }, + { + "cell": "0.1", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.2", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.3", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.4", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.5", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.6", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.7", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.8", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0.9", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "1.0", + "pum_table_id": "F0177", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-01" + }, + { + "cell": "0∼40m", + "pum_table_id": "F0178", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-02" + }, + { + "cell": "0∼60m", + "pum_table_id": "F0178", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-02" + }, + { + "cell": "0∼80m", + "pum_table_id": "F0178", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-02" + }, + { + "cell": "0∼100m", + "pum_table_id": "F0178", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-02" + }, + { + "cell": "0∼120m", + "pum_table_id": "F0178", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-05-02" + }, + { + "cell": "26㎥", + "pum_table_id": "F0179", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-06" + }, + { + "cell": "0.1", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.2", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.3", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.4", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.5", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.6", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.7", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.8", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0.9", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "1.0", + "pum_table_id": "F0180", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-01" + }, + { + "cell": "0∼20㎥", + "pum_table_id": "F0181", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-02" + }, + { + "cell": "21∼40㎥", + "pum_table_id": "F0181", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-02" + }, + { + "cell": "41∼60㎥", + "pum_table_id": "F0181", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-02" + }, + { + "cell": "61∼80㎥", + "pum_table_id": "F0181", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-02" + }, + { + "cell": "81∼100㎥", + "pum_table_id": "F0181", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-07-02" + }, + { + "cell": "상향집재", + "pum_table_id": "F0182", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-01" + }, + { + "cell": "하향집재", + "pum_table_id": "F0182", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-01" + }, + { + "cell": "상향집재", + "pum_table_id": "F0182", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-08-01" + }, + { + "cell": "하향집재", + "pum_table_id": "F0182", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-08-01" + }, + { + "cell": "상향집재", + "pum_table_id": "F0183", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-02" + }, + { + "cell": "하향집재", + "pum_table_id": "F0183", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-02" + }, + { + "cell": "상향집재", + "pum_table_id": "F0183", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-08-02" + }, + { + "cell": "하향집재", + "pum_table_id": "F0183", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-08-02" + }, + { + "cell": "0.1", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.2", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.3", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.4", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.5", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.6", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.7", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.8", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0.9", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "1.0", + "pum_table_id": "F0184", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-08-03" + }, + { + "cell": "0~20㎥", + "pum_table_id": "F0185", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-01" + }, + { + "cell": "21~40㎥", + "pum_table_id": "F0185", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-01" + }, + { + "cell": "41~60㎥", + "pum_table_id": "F0185", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-01" + }, + { + "cell": "61~80㎥", + "pum_table_id": "F0185", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-01" + }, + { + "cell": "81~100㎥", + "pum_table_id": "F0185", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-01" + }, + { + "cell": "0~20㎥", + "pum_table_id": "F0186", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-02" + }, + { + "cell": "21~40㎥", + "pum_table_id": "F0186", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-02" + }, + { + "cell": "41~60㎥", + "pum_table_id": "F0186", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-02" + }, + { + "cell": "61~80㎥", + "pum_table_id": "F0186", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-02" + }, + { + "cell": "81~100㎥", + "pum_table_id": "F0186", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-09-02" + }, + { + "cell": "작업량", + "pum_table_id": "F0188", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-10" + }, + { + "cell": "작업량", + "pum_table_id": "F0188", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-10" + }, + { + "cell": "원목집재와 동시에 부산물 수집시", + "pum_table_id": "F0191", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-11" + }, + { + "cell": "1.8m", + "pum_table_id": "F0192", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-12" + }, + { + "cell": "2.1m", + "pum_table_id": "F0192", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-12" + }, + { + "cell": "2.7m", + "pum_table_id": "F0192", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-12" + }, + { + "cell": "3.6m", + "pum_table_id": "F0192", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-07-12" + }, + { + "cell": "생산목 검척", + "pum_table_id": "F0193", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-07-13" + }, + { + "cell": "롤트랩 설치", + "pum_table_id": "F0223", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-04" + }, + { + "cell": "롤트랩 제거", + "pum_table_id": "F0223", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-04" + }, + { + "cell": "롤트랩 설치", + "pum_table_id": "F0223", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-04" + }, + { + "cell": "롤트랩 제거", + "pum_table_id": "F0223", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-04" + }, + { + "cell": "트랩설치", + "pum_table_id": "F0224", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-05" + }, + { + "cell": "트랩통수거 및 교체", + "pum_table_id": "F0224", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-05" + }, + { + "cell": "트랩철거", + "pum_table_id": "F0224", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-05" + }, + { + "cell": "유인헬기", + "pum_table_id": "F0226", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-01" + }, + { + "cell": "무인헬기 (ha당)", + "pum_table_id": "F0228", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "이착륙장 정리(1개소/4ha)", + "pum_table_id": "F0228", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "취수 및 약제조제", + "pum_table_id": "F0228", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "약제살포", + "pum_table_id": "F0228", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "주유 및 약제충전 기체정비", + "pum_table_id": "F0228", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "비행준비", + "pum_table_id": "F0228", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "멀티콥터 (ha당)", + "pum_table_id": "F0229", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "이착륙장 정리(1개소/4ha)", + "pum_table_id": "F0229", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "취수 및 약제조제", + "pum_table_id": "F0229", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "약제살포", + "pum_table_id": "F0229", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "기체 및 약제충전 기체정비", + "pum_table_id": "F0229", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "비행준비", + "pum_table_id": "F0229", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-06-02" + }, + { + "cell": "사전조사", + "pum_table_id": "F0230", + "reason": "숫자 칸 1 개가 자원 열 2 개와 안 맞아 버렸습니다(자리 밀림 방지).", + "work_item_code": "FP-08-06-03" + }, + { + "cell": "운전", + "pum_table_id": "F0230", + "reason": "숫자 칸 1 개가 자원 열 2 개와 안 맞아 버렸습니다(자리 밀림 방지).", + "work_item_code": "FP-08-06-03" + }, + { + "cell": "물주입, 살포", + "pum_table_id": "F0230", + "reason": "숫자 칸 1 개가 자원 열 2 개와 안 맞아 버렸습니다(자리 밀림 방지).", + "work_item_code": "FP-08-06-03" + }, + { + "cell": "방제실행 등록", + "pum_table_id": "F0232", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-07" + }, + { + "cell": "1.0RM 이하", + "pum_table_id": "F0233", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-08-01" + }, + { + "cell": "1.1~1.5RM", + "pum_table_id": "F0233", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-08-01" + }, + { + "cell": "1.6~2.0RM", + "pum_table_id": "F0233", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-08-01" + }, + { + "cell": "굴 삭 기 우드그랩", + "pum_table_id": "F0234", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-08-02" + }, + { + "cell": "인 력", + "pum_table_id": "F0234", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-08-08-02" + }, + { + "cell": "잔가지 줍기", + "pum_table_id": "F0235", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-08-09" + }, + { + "cell": "특별인부 | 5인/km | 10인/km", + "pum_table_id": "F0238", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-02" + }, + { + "cell": "대형브레이커+ 유압식백호우 (무한궤도,0.7㎥)", + "pum_table_id": "F0241", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-04-01" + }, + { + "cell": "보통암", + "pum_table_id": "F0241", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-04-01" + }, + { + "cell": "경 암", + "pum_table_id": "F0241", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-04-01" + }, + { + "cell": "폭 약", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "뇌 관", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "비 트", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "화 약 공", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "착 암 기", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "공기압축기", + "pum_table_id": "F0243", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-01" + }, + { + "cell": "연 암", + "pum_table_id": "F0244", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-02" + }, + { + "cell": "보통암", + "pum_table_id": "F0244", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-02" + }, + { + "cell": "경 암", + "pum_table_id": "F0244", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-05-02" + }, + { + "cell": "대형브레이커+유압식백호우 (무한궤도,0.7㎥)", + "pum_table_id": "F0246", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-06-01" + }, + { + "cell": "아세틸렌", + "pum_table_id": "F0251", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-08-01" + }, + { + "cell": "산소", + "pum_table_id": "F0251", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-08-01" + }, + { + "cell": "메쌓기", + "pum_table_id": "F0253", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-09" + }, + { + "cell": "뒷길이60㎝이상", + "pum_table_id": "F0253", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-09" + }, + { + "cell": "찰쌓기", + "pum_table_id": "F0253", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-09" + }, + { + "cell": "아스팔트", + "pum_table_id": "F0255", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-09-10-02" + }, + { + "cell": "콘크리트커트", + "pum_table_id": "F0256", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-11-01" + }, + { + "cell": "브레이드", + "pum_table_id": "F0256", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-11-01" + }, + { + "cell": "잡자재", + "pum_table_id": "F0256", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-11-01" + }, + { + "cell": "대형브레이커(㎥/hr)", + "pum_table_id": "F0259", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-02" + }, + { + "cell": "치즐소모(본/hr)", + "pum_table_id": "F0259", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-02" + }, + { + "cell": "대형브레이커(㎥/hr)", + "pum_table_id": "F0260", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-03" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0260", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-12-03" + }, + { + "cell": "장비 (90%) | 유압식백호우 (무한궤도,0.7㎥) | 육상토사(0~1m)와 동일", + "pum_table_id": "F0262", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-02" + }, + { + "cell": "장비 (90%) | 유압식백호우 (무한궤도,0.7㎥) | 육상토사(0~1m)와 동일", + "pum_table_id": "F0263", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-03" + }, + { + "cell": "장비 (90%) | 유압식백호우 (무한궤도,0.7㎥) | 용수토사(0~1m)와 동일", + "pum_table_id": "F0265", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-05" + }, + { + "cell": "장비 (90%) | 유압식백호우 (무한궤도,0.7㎥) | 용수토사(0~1m)와 동일", + "pum_table_id": "F0266", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-06" + }, + { + "cell": "깨기", + "pum_table_id": "F0267", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-07" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0267", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-07" + }, + { + "cell": "깨기", + "pum_table_id": "F0268", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-08" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0268", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-08" + }, + { + "cell": "들어내기 | 유압식백호우 (무한궤도,0.7㎥) | 육상 암절취(0~1m)와 동일", + "pum_table_id": "F0268", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-08" + }, + { + "cell": "깨기", + "pum_table_id": "F0269", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-09" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0269", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-09" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 육상 암절취(0~1m)와 동일", + "pum_table_id": "F0269", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-09" + }, + { + "cell": "깨기", + "pum_table_id": "F0270", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-10" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0270", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-10" + }, + { + "cell": "깨기", + "pum_table_id": "F0271", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-11" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0271", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-11" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 용수 암절취(0~1m)와 동일", + "pum_table_id": "F0271", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-11" + }, + { + "cell": "깨기", + "pum_table_id": "F0272", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-12" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0272", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-12" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 용수 암절취(0~1m)와 동일", + "pum_table_id": "F0272", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-12" + }, + { + "cell": "깨기", + "pum_table_id": "F0273", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-13" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0273", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-13" + }, + { + "cell": "깨기", + "pum_table_id": "F0274", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-14" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0274", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-14" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 육상 발파암(1~2m)와 동일", + "pum_table_id": "F0274", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-14" + }, + { + "cell": "깨기", + "pum_table_id": "F0275", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-15" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0275", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-15" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 육상 발파암(1~2m)와 동일", + "pum_table_id": "F0275", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-15" + }, + { + "cell": "깨기", + "pum_table_id": "F0276", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-16" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0276", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-16" + }, + { + "cell": "깨기", + "pum_table_id": "F0277", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-17" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0277", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-17" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 용수 발파암(0~1m)와 동일", + "pum_table_id": "F0277", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-17" + }, + { + "cell": "깨기", + "pum_table_id": "F0278", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-18" + }, + { + "cell": "치즐소모량(본/hr)", + "pum_table_id": "F0278", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-13-18" + }, + { + "cell": "들어 내기 | 유압식백호우 (무한궤도,0.7㎥) | 용수 발파암(0~1m)와 동일", + "pum_table_id": "F0278", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-09-13-18" + }, + { + "cell": "모래ㆍ사질토ㆍ점토ㆍ점질토", + "pum_table_id": "F0288", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "연질토ㆍ불순자갈", + "pum_table_id": "F0288", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "호박돌 섞인 고결토ㆍ경질토", + "pum_table_id": "F0288", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "풍화암", + "pum_table_id": "F0288", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "연암", + "pum_table_id": "F0288", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "보통암ㆍ경암", + "pum_table_id": "F0288", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "인력시공", + "pum_table_id": "F0289", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "기계시공", + "pum_table_id": "F0289", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-01" + }, + { + "cell": "공기압축기(3.5㎥/min)", + "pum_table_id": "F0291", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-03" + }, + { + "cell": "소형브레이커", + "pum_table_id": "F0291", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-03" + }, + { + "cell": "어어호스(3/4인치)", + "pum_table_id": "F0291", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-09-19-03" + }, + { + "cell": "거리 (m) | 보통인부(인) | 거리 (m) | 보통인부(인) | 비고", + "pum_table_id": "F0302", + "reason": "열 머리가 되풀이되는 두 판 짜리 표 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-10-06-01" + }, + { + "cell": "작업반장", + "pum_table_id": "F0311", + "reason": "값 묶음 0 개가 갈래 3 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-10-07-04" + }, + { + "cell": "콘크리트", + "pum_table_id": "F0313", + "reason": "숫자 칸 2 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "제 자재", + "pum_table_id": "F0313", + "reason": "숫자 칸 2 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "〃", + "pum_table_id": "F0313", + "reason": "같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "〃", + "pum_table_id": "F0313", + "reason": "같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "콘크리트", + "pum_table_id": "F0313", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "제 자재", + "pum_table_id": "F0313", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "〃", + "pum_table_id": "F0313", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "〃", + "pum_table_id": "F0313", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "콘크리트", + "pum_table_id": "F0313", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "제 자재", + "pum_table_id": "F0313", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-10-08-01" + }, + { + "cell": "운반기구 손료", + "pum_table_id": "F0315", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-10-08-03" + }, + { + "cell": "보통인부 | | 인 〃 〃 〃", + "pum_table_id": "F0317", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-10-10-01" + }, + { + "cell": "버켓 손료", + "pum_table_id": "F0317", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-10-10-01" + }, + { + "cell": "보통인부 | | 인 〃 〃 〃", + "pum_table_id": "F0318", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-10-10-02" + }, + { + "cell": "와이어 손료", + "pum_table_id": "F0318", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-10-10-02" + }, + { + "cell": "슬 럼 프 | 기준 시공량", + "pum_table_id": "F0356", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「8 ~ 12 cm」을 못 풀었습니다", + "work_item_code": "FP-12-02" + }, + { + "cell": "합 판", + "pum_table_id": "F0336", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-04" + }, + { + "cell": "각 재", + "pum_table_id": "F0336", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-04" + }, + { + "cell": "철 선", + "pum_table_id": "F0336", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-04" + }, + { + "cell": "못", + "pum_table_id": "F0336", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-04" + }, + { + "cell": "박 리 제", + "pum_table_id": "F0336", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-04" + }, + { + "cell": "형틀목공", + "pum_table_id": "F0336", + "reason": "값 묶음 1 개가 갈래 3 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-12-04" + }, + { + "cell": "문양 스티로폴(자재비 포함)", + "pum_table_id": "F0337", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-05" + }, + { + "cell": "배치인원(인) | 포장두께 | 시공량(㎥)", + "pum_table_id": "F0338", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「30㎝」을 못 풀었습니다", + "work_item_code": "FP-12-06" + }, + { + "cell": "배치인원(인) | 사용기계(1대) | 시공량(m)", + "pum_table_id": "F0339", + "reason": "작업조 표를 못 읽었습니다 — 유형 4 개와 시공량 1 개가 안 맞습니다", + "work_item_code": "FP-12-07-01" + }, + { + "cell": "배치인원(인) | 시공량(거푸집연장 m)", + "pum_table_id": "F0341", + "reason": "작업조 표를 못 읽었습니다 — 작업조 줄 「20㎝ ≤ 포장두께 ≤ 25㎝」을 못 풀었습니다", + "work_item_code": "FP-12-08" + }, + { + "cell": "거 푸 집", + "pum_table_id": "F0342", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-09-01" + }, + { + "cell": "연결철근", + "pum_table_id": "F0342", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-09-01" + }, + { + "cell": "거 푸 집", + "pum_table_id": "F0343", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-09-02" + }, + { + "cell": "철근가공조립", + "pum_table_id": "F0343", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-09-02" + }, + { + "cell": "거 푸 집", + "pum_table_id": "F0344", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-09-03" + }, + { + "cell": "유공관 설치", + "pum_table_id": "F0345", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-10" + }, + { + "cell": "소운반 인부", + "pum_table_id": "F0345", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-10" + }, + { + "cell": "흄 관", + "pum_table_id": "F0347", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-11-02" + }, + { + "cell": "거 푸 집", + "pum_table_id": "F0347", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-11-02" + }, + { + "cell": "크레인", + "pum_table_id": "F0347", + "reason": "값 묶음 3 개가 갈래 3 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-12-11-02" + }, + { + "cell": "절단기", + "pum_table_id": "F0348", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-12-11-02" + }, + { + "cell": "다짐:봉상후렉시블(45mm)", + "pum_table_id": "F0350", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-12" + }, + { + "cell": "다짐:봉상후렉시블(45mm)", + "pum_table_id": "F0351", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-13" + }, + { + "cell": "철 거", + "pum_table_id": "F0352", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-14" + }, + { + "cell": "다짐:봉상후렉시블(45mm)", + "pum_table_id": "F0353", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-15" + }, + { + "cell": "설치비", + "pum_table_id": "F0353", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-15" + }, + { + "cell": "봉상후렉시블(45mm)", + "pum_table_id": "F0354", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-16" + }, + { + "cell": "버림콘크리트 (레미콘)", + "pum_table_id": "F0354", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-16" + }, + { + "cell": "원형거푸집 (PE10회)", + "pum_table_id": "F0354", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-16" + }, + { + "cell": "설치비", + "pum_table_id": "F0354", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-16" + }, + { + "cell": "보통인부", + "pum_table_id": "F0354", + "reason": "값 묶음 0 개가 갈래 4 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-12-16" + }, + { + "cell": "콘크리트펌프차", + "pum_table_id": "F0355", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-17-01" + }, + { + "cell": "구체콘크리트 (철근)", + "pum_table_id": "F0363", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-17-02" + }, + { + "cell": "펌프카(80㎥/hr)", + "pum_table_id": "F0363", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-17-02" + }, + { + "cell": "콘크리트다짐", + "pum_table_id": "F0363", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-17-02" + }, + { + "cell": "결속선(R=0.9mm)", + "pum_table_id": "F0365", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-18" + }, + { + "cell": "기구손료(노무비의)", + "pum_table_id": "F0365", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-18" + }, + { + "cell": "강관(∅48.6mm×2.4mm)", + "pum_table_id": "F0366", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-19" + }, + { + "cell": "이음철물", + "pum_table_id": "F0366", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-19" + }, + { + "cell": "조임철물", + "pum_table_id": "F0366", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-19" + }, + { + "cell": "받침철물", + "pum_table_id": "F0366", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-19" + }, + { + "cell": "철 물", + "pum_table_id": "F0366", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-19" + }, + { + "cell": "강관 동바리", + "pum_table_id": "F0367", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-20" + }, + { + "cell": "외관(60.6mm×2.3mm)", + "pum_table_id": "F0367", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-20" + }, + { + "cell": "잡재료비(재료비의)", + "pum_table_id": "F0367", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-20" + }, + { + "cell": "아스팔트(㏊-500)", + "pum_table_id": "F0368", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-12-21" + }, + { + "cell": "부 직 포", + "pum_table_id": "F0370", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-23" + }, + { + "cell": "잡재료비(재료비의)", + "pum_table_id": "F0370", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-23" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "f", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "E", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "㎝(sec)", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "살수", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "다짐", + "pum_table_id": "F0372", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-02" + }, + { + "cell": "운반 및 설치 (목도운반)", + "pum_table_id": "F0374", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-26" + }, + { + "cell": "인력 (10%)", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "장비 (90%)", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "f", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "E", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "㎝(sec)", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "살수", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "다짐", + "pum_table_id": "F0371", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-12-24-01" + }, + { + "cell": "접착제", + "pum_table_id": "F0376", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-27-02" + }, + { + "cell": "실런트", + "pum_table_id": "F0377", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-27-03" + }, + { + "cell": "에폭시 접착제", + "pum_table_id": "F0378", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-12-28" + }, + { + "cell": "시너", + "pum_table_id": "F0378", + "reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음", + "work_item_code": "FP-12-28" + }, + { + "cell": "결속선(R-0.9mm)", + "pum_table_id": "F0387", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-34-03" + }, + { + "cell": "고철대(감)", + "pum_table_id": "F0387", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-12-34-03" + }, + { + "cell": "굴착기", + "pum_table_id": "F0399", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-02-03" + }, + { + "cell": "집게 장치", + "pum_table_id": "F0399", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-02-03" + }, + { + "cell": "인 부", + "pum_table_id": "F0401", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-02-04" + }, + { + "cell": "증가율(%)", + "pum_table_id": "F0405", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-03" + }, + { + "cell": "기초다짐 뒷채움", + "pum_table_id": "F0403", + "reason": "숫자 칸 4 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-03-02" + }, + { + "cell": "75이상", + "pum_table_id": "F0403", + "reason": "숫자 칸 4 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-03-02" + }, + { + "cell": "기초다짐 뒷채움", + "pum_table_id": "F0403", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-03-02" + }, + { + "cell": "75이상", + "pum_table_id": "F0403", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-03-02" + }, + { + "cell": "기초다짐 뒷채움", + "pum_table_id": "F0403", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-03-02" + }, + { + "cell": "석공 (인)", + "pum_table_id": "F0404", + "reason": "값 묶음 0 개가 갈래 6 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-04-01" + }, + { + "cell": "석 공 (인)", + "pum_table_id": "F0409", + "reason": "값 묶음 0 개가 갈래 6 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-04-04" + }, + { + "cell": "메쌓기(㎝) 찰쌓기(㎝)", + "pum_table_id": "F0412", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-04-04" + }, + { + "cell": "절토", + "pum_table_id": "F0413", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-04-04" + }, + { + "cell": "찰쌓기", + "pum_table_id": "F0413", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-04-04" + }, + { + "cell": "절토", + "pum_table_id": "F0413", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-04-04" + }, + { + "cell": "25 30 35 45 55 60 70", + "pum_table_id": "F0416", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-05-01" + }, + { + "cell": "굴 삭 기 (무한궤도)", + "pum_table_id": "F0419", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-06-01" + }, + { + "cell": "굴 삭 기 (무한궤도)", + "pum_table_id": "F0423", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-07-02" + }, + { + "cell": "굴 삭 기", + "pum_table_id": "F0425", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-09" + }, + { + "cell": "보통 인부 (인) | 0.9m 1.2m 1.5m 1.8m 2.1m 2.4m 2.7m 3.0m 3.5m 4.0m 4.5m | 0.022 0.034 0.05 0.07 - - - - - - -", + "pum_table_id": "F0427", + "reason": "자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "보 통 인 부 (인)", + "pum_table_id": "F0428", + "reason": "값 묶음 8 개가 갈래 8 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "8 9", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "12", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "15", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "18", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "21", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "24", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "27", + "pum_table_id": "F0430", + "reason": "숫자 칸 8 개가 자원 열 3 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-10-02" + }, + { + "cell": "조약돌량(㎥)", + "pum_table_id": "F0431", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-01" + }, + { + "cell": "인력(인)", + "pum_table_id": "F0431", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-01" + }, + { + "cell": "돌 채 움", + "pum_table_id": "F0431", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-01" + }, + { + "cell": "굴착기(1.0㎥)", + "pum_table_id": "F0432", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-01" + }, + { + "cell": "조약돌량(㎥)", + "pum_table_id": "F0433", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-02" + }, + { + "cell": "인력(인)", + "pum_table_id": "F0433", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-02" + }, + { + "cell": "돌 채 움", + "pum_table_id": "F0433", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-02" + }, + { + "cell": "굴착기 (1.0㎥)", + "pum_table_id": "F0434", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-02" + }, + { + "cell": "철망태", + "pum_table_id": "F0436", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-04" + }, + { + "cell": "채움재", + "pum_table_id": "F0436", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-04" + }, + { + "cell": "잡재료", + "pum_table_id": "F0436", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-11-04" + }, + { + "cell": "절 취(㎥)", + "pum_table_id": "F0437", + "reason": "숫자 칸 3 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "투 입(㎥)", + "pum_table_id": "F0437", + "reason": "숫자 칸 3 개가 자원 열 1 개보다 많습니다 — 갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "절 취(㎥)", + "pum_table_id": "F0437", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "투 입(㎥)", + "pum_table_id": "F0437", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "면고르기(시간)", + "pum_table_id": "F0437", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "절 취(㎥)", + "pum_table_id": "F0437", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "투 입(㎥)", + "pum_table_id": "F0437", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "면고르기(시간)", + "pum_table_id": "F0437", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-12-01" + }, + { + "cell": "보통구조", + "pum_table_id": "F0440", + "reason": "자원 열의 값이 한 칸 밀린 행 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-13-01" + }, + { + "cell": "상", + "pum_table_id": "F0440", + "reason": "같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-13-01" + }, + { + "cell": "중등구조", + "pum_table_id": "F0440", + "reason": "자원 열의 값이 한 칸 밀린 행 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-13-01" + }, + { + "cell": "상", + "pum_table_id": "F0440", + "reason": "같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-13-01" + }, + { + "cell": "특 별 인 부", + "pum_table_id": "F0442", + "reason": "값 묶음 1 개가 갈래 2 개와 안 맞습니다 — 자리를 단정할 수 없어 버렸습니다.", + "work_item_code": "FP-13-13-02" + }, + { + "cell": "어려운 조건", + "pum_table_id": "F0447", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-15-02" + }, + { + "cell": "쉬운 조건", + "pum_table_id": "F0447", + "reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)", + "work_item_code": "FP-13-15-02" + }, + { + "cell": "잠 수 조", + "pum_table_id": "F0448", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-16-01" + }, + { + "cell": "굴 삭 기", + "pum_table_id": "F0448", + "reason": "뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.", + "work_item_code": "FP-13-16-01" + } + ], + "schema_version": "1.0" +} \ No newline at end of file diff --git a/resources/data_formwork/formwork_reuse_2026-01-01.json b/resources/data_formwork/formwork_reuse_2026-01-01.json new file mode 100644 index 00000000..a8cc0429 --- /dev/null +++ b/resources/data_formwork/formwork_reuse_2026-01-01.json @@ -0,0 +1,166 @@ +{ + "schema_version": "1.0", + "dataset_id": "formwork_reuse", + "effective_date": "2026-01-01", + "note": "거푸집 사용횟수 — **품셈 1-7-1 원문**이 구조물 종류별로 정해 둔 값이다. 관측값이 아니라 법이므로 실무값으로 갈음하지 않는다.", + "source": { + "doc": "산림사업 표준품셈 1-7-1 거푸집 사용", + "table_id": "F0040", + "quote": "2회 T형보, 난간, 특히 복잡한 구조의 교각, 교대, 수문관의 본체 등 복잡한 구조 / 3회 슬래브, 교대, 교각, 옹벽, 파라펫트, 날개벽 등 약간 복잡한 구조 / 4회 측구, 수로, 확대기초, 우물통 등 비교적 간단한 구조 / 6회 수문 또는 관의 기초, 호안 및 보호공의 기초 등 극히 간단한 구조" + }, + "policy": { + "b08_delivers": "접촉 면적(㎡) + 사용횟수. **횟수별 재료 환산은 하지 않는다**.", + "b09_applies": "품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」은 일위대가 재료비에 걸린다. B08 이 여기서 곱하면 B09 와 겹쳐 두 번 준다.", + "unlisted_is_flagged": true + }, + "reuse_by_class": [ + { + "reuse_count": 2, + "class": "복잡한 구조", + "examples": [ + "T형보", + "난간", + "복잡한 교각", + "교대", + "수문관 본체" + ] + }, + { + "reuse_count": 3, + "class": "약간 복잡한 구조", + "examples": [ + "슬래브", + "교대", + "교각", + "옹벽", + "파라펫트", + "날개벽" + ] + }, + { + "reuse_count": 4, + "class": "비교적 간단한 구조", + "examples": [ + "측구", + "수로", + "확대기초", + "우물통" + ] + }, + { + "reuse_count": 6, + "class": "극히 간단한 구조", + "examples": [ + "수문 기초", + "관의 기초", + "호안 기초", + "보호공 기초" + ] + } + ], + "type_map": [ + { + "type_id": "retaining_wall", + "reuse_count": 3, + "matched_example": "옹벽", + "note": "원문 3회 줄에 「옹벽」이 그대로 있음" + }, + { + "type_id": "pipe_inlet_basin", + "reuse_count": 6, + "matched_example": "보호공 기초", + "note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요" + }, + { + "type_id": "ford_pavement", + "reuse_count": null, + "note": "물넘이포장은 거푸집이 서지 않는 구조(면 포장) — 대상 아님" + } + ], + "reuse_ratio_pct": { + "note": "품셈 12-4 「사용횟수별 기준수량에 대한 비율(%)」. **B09 일위대가가 쓰는 값**이며 B08 은 참고로만 싣는다 — 여기서 곱하면 이중계상.", + "table_id": "F0336", + "plywood": { + "1": 100.0, + "2": 57.0, + "3": 46.1, + "4": 40.1, + "5": 37.1, + "6": 34.7 + }, + "timber": { + "1": 100.0, + "2": 60.0, + "3": 47.1, + "4": 40.0, + "5": 34.2, + "6": 32.0 + } + }, + "shoring": { + "note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.", + "targets_pending": [ + "box_culvert", + "ford_bridge" + ], + "why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보" + }, + "euroform_type": { + "note": "유로폼 설치·해체 유형(간단/보통/복잡) — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. 거푸집 사용횟수(1-7-1)·철근 갈래(12-3 [주]①)와 같은 자리로, 사람이 고르는 값이 아니다.", + "source": { + "doc": "산림사업 표준품셈 12-38-3 설치 및 해체 [주]④", + "table_id": "F0396", + "quote": "복잡 — 토목: 교대, 날개벽 등 복잡하고 보강이 많은 구조 / 보통 — 측구, 수로, 옹벽, 일반적인 벽체, 박스 등 / 간단 — 수문 또는 관의 기초, 건축 매트기초 등 간단한 구조" + }, + "classes": [ + { + "key": "복잡", + "examples": [ + "교대", + "날개벽" + ], + "daily_area_m2": 25 + }, + { + "key": "보통", + "examples": [ + "측구", + "수로", + "옹벽", + "일반적인 벽체", + "박스" + ], + "daily_area_m2": 35 + }, + { + "key": "간단", + "examples": [ + "수문 기초", + "관의 기초", + "매트기초" + ], + "daily_area_m2": 40 + } + ], + "type_map": [ + { + "type_id": "retaining_wall", + "class": "보통", + "matched": "옹벽" + }, + { + "type_id": "box_culvert", + "class": "보통", + "matched": "박스" + }, + { + "type_id": "pipe_inlet_basin", + "class": "간단", + "matched": "관의 기초", + "note": "⚠ 집수정 벽체까지 간단으로 볼지는 확인 필요 — 거푸집 사용횟수와 같은 물음" + } + ], + "reuse_note": "⚠ 12-38-1 「사용횟수」는 **1-7-1 과 다른 자리다.** 1-7-1 은 합판거푸집처럼 **소모성 거푸집을 몇 번 쓰나**(옹벽 3회)이고, 12-38-1 은 유로폼(강재)의 **잔존율 기준**(12회 사용 잔존율 25 % · 25회 사용 잔존율 10 %)으로 **임대료·손료 산정용**이다. 하나로 잇지 않는다.", + "material_unit_note": "유로폼 자재는 12-38-2 가 **10㎡당 패널 0.89매**로 낸다. B08 은 **접촉 면적(㎡)** 으로 보내고 매수 환산은 그 표의 밑수(10㎡)를 아는 B09 가 한다 — B08 이 환산하면 밑수를 두 벌로 들게 된다." + } +} diff --git a/resources/data_masonry/masonry_class_2026-01-01.json b/resources/data_masonry/masonry_class_2026-01-01.json new file mode 100644 index 00000000..6ed74845 --- /dev/null +++ b/resources/data_masonry/masonry_class_2026-01-01.json @@ -0,0 +1,54 @@ +{ + "schema_version": "1.0", + "dataset_id": "masonry_class", + "effective_date": "2026-01-01", + "note": "돌쌓기(13-4)·큰돌쌓기(13-6) 의 **규격 갈래**. 저장 제원의 값으로 자동 판정하며 사람이 고르는 값이 아니다.", + "back_length": { + "note": "돌쌓기 — 저장 제원 `back_len_cm`(㎝). 품셈 갈래는 「…㎝ 이하」 구간이라 **저장값 이상인 첫 경계**를 고른다.", + "option_key": "back_len_cm", + "source": "품셈 13-4 뒷길이 표준 · B09 단가 갈래(#35cm이하·#55cm이하·#75cm이하)", + "classes": [ + { + "max_cm": 35, + "key": "35cm이하" + }, + { + "max_cm": 55, + "key": "55cm이하" + }, + { + "max_cm": 75, + "key": "75cm이하" + } + ] + }, + "boulder_diameter": { + "note": "큰돌쌓기 — 저장 제원 `stone_cm`. **품셈 13-6 직경 축과 글자까지 같다** — 접거나 바꾸지 않고 그대로 쓴다.", + "option_key": "stone_cm", + "source": "품셈 13-6 「직경 40㎝이상∼60㎝미만 / 60㎝이상∼80㎝미만 / 80㎝이상∼100㎝이하」", + "classes": [ + "40~60", + "60~80", + "80~100" + ] + }, + "face_slope": { + "note": "전면 기울기(1:n) — **교본 7-3 돌흙막이 기준**이 형식별로 정해 둔다. 코드의 기본 0.3 은 지어낸 값이 아니라 이 표에서 온 것이다.", + "source": "resources/knowledge/technical_info/01_임도/02_상세설계/구조물/돌쌓기.md §1 (교본 7-3)", + "quote": "돌 찰쌓기 3.0m 이하 1:0.3 / 돌 메쌓기 2.0m 이하 1:0.3 / 큰돌쌓기 1:0.3 이상(전도 방지)", + "by_type": { + "masonry_wet": 0.3, + "masonry_dry": 0.3, + "boulder_masonry": 0.3 + }, + "pending": "⚠ 큰돌쌓기는 「1:0.3 **이상**」이라 더 완만하게 잡을 수 있음 — 칸을 만든다면 그 범위를 보여야 함. 지금은 하한 0.3 으로 감." + }, + "bond": { + "note": "큰돌쌓기 쌓기 방식 — 저장 제원 `bond`. 품셈 13-6-1(메)·13-6-2(찰)로 그대로 갈린다.", + "option_key": "bond", + "codes": { + "메쌓기": "FP-13-06-01", + "찰쌓기": "FP-13-06-02" + } + } +} diff --git a/resources/data_masonry/stone_kind_2026-01-01.json b/resources/data_masonry/stone_kind_2026-01-01.json new file mode 100644 index 00000000..3821fa2a --- /dev/null +++ b/resources/data_masonry/stone_kind_2026-01-01.json @@ -0,0 +1,164 @@ +{ + "schema_version": "1.0", + "dataset_id": "stone_kind_unit", + "effective_date": "2026-01-01", + "note": "돌쌓기 계수는 **돌 종류로 갈린다**. 지금까지 한 벌(깬돌 계열)로만 돌고 있었다 — 2026-09-08 지식DB 대조에서 드러났다.", + "why": "품셈 13-4-3(고임돌)은 돌 종류로 **네 줄**, 13-4-4 [주]①(채움 콘크리트)은 **두 줄**, 교본 7-3 뒤채움은 **두 값**이다. 세 곳이 같은 축인데 우리는 축 없이 하나로 돌고 있었다.", + "option_key": "stone_kind", + "option_note": "레지스트리 `masonry_wet`·`masonry_dry` 의 옵션. 2026-09-08 랩탑 창이 만들었고 키 이름을 두 창이 미리 맞췄다(`back_len_cm` 이름 어긋남 사고를 되풀이하지 않으려고).", + "sources": { + "forest_13_4_3": { + "doc": "산림사업 표준품셈 13-4-3 고임돌 소요량 (단위: ㎥/㎡당)", + "note": "돌 종류 네 줄. 「-」는 그 규격에 그 돌을 안 쓴다는 뜻이라 지어내지 않는다." + }, + "forest_13_4_4_note1": { + "doc": "산림사업 표준품셈 13-4-4 [주]① 찰쌓기 및 찰붙임의 채움 콘크리트 소요량 (㎥/㎡당)", + "note": "두 줄뿐 — 야면석·호박돌(뒷길이의 33.3 %) / 깬잡석·깬돌·견치돌(45 %)." + }, + "textbook_7_3": { + "doc": "임도기술교본 7장 3절 (지식DB 돌쌓기.md §4 · 흙막이.md §4)", + "quote": "뒤채움 = 돌쌓기 표면적 × 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)" + }, + "construction_reference": { + "doc": "건설공사 표준품셈 [참고자료] 돌쌓기 규격별 소요량", + "note": "⚠ **돌 종류로 안 갈리는 한 벌**이고, 그 값이 우리가 지금까지 쓰던 값이다(고임돌 = 깬돌 줄 · 채움 콘크리트 0.16/0.20/0.25/0.27). 2026 개정안이 이 참고자료를 **「삭제 검토」** 로 두었다." + } + }, + "policy": { + "primary": "산림사업이므로 **산림품셈이 1차 적용**(CLAUDE.md 3장). 건설품셈 값은 미지정일 때의 기본값 근거로만 쓴다.", + "unset_is_flagged": "돌 종류를 안 고르면 **건설품셈 참고자료 한 벌**로 돌되 그 사실을 알린다 — 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.", + "no_interpolation": "표에 없는 뒷길이·「-」 칸은 **지어내지 않는다.**" + }, + "kinds": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "wedge_stone_m3_per_m2": { + "note": "고임돌 — 품셈 13-4-3. 키는 뒷길이(㎝). `null` 은 원문 「-」.", + "야면석·호박돌": { + "25": 0.06, + "30": 0.07, + "35": 0.09, + "45": 0.11, + "55": 0.14, + "60": 0.15, + "75": null + }, + "깬잡석": { + "25": 0.09, + "30": 0.11, + "35": 0.13, + "45": 0.16, + "55": 0.19, + "60": 0.21, + "75": 0.26 + }, + "깬돌": { + "25": null, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "견치돌": { + "25": null, + "30": null, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + } + }, + "fill_concrete_m3_per_m2": { + "note": "채움 콘크리트 — 품셈 13-4-4 [주]①. **두 줄뿐**이라 견치돌·깬잡석·깬돌이 한 값을 쓴다.", + "야면석·호박돌": { + "25": 0.08, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "깬잡석": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "깬돌": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "견치돌": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + } + }, + "backfill_ratio_of_back_length": { + "note": "뒤채움이 뒷길이에서 차지하는 몫 — 교본 7-3. 나머지가 잡석(막자갈)이 된다.", + "야면석·호박돌": 0.3333333333333333, + "깬잡석": 0.5, + "깬돌": 0.5, + "견치돌": 0.5 + }, + "fallback": { + "note": "돌 종류 미지정일 때 — 건설품셈 [참고자료] 돌쌓기 규격별 소요량 한 벌(고임돌은 「깬돌」 열). ⚠ 일곱 규격을 다 싣는다 — 앞서 네 칸만 들고 있어 25·30·75 를 가까운 칸으로 **접어 올리고** 있었다.", + "wedge_stone_m3_per_m2": { + "25": null, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "fill_concrete_m3_per_m2": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "backfill_ratio_of_back_length": 0.3333333333333333, + "backfill_note": "⚠ 이 값은 **뒤채움이 차지하는 몫**이다. 우리 막자갈 식은 입적에서 **돌 몸통**을 빼므로 코드가 `1 − 이 값` 을 쓴다. 종전 하드코딩 2/3 이 곧 야면석(1 − 1/3)이었다.", + "message": "돌 종류를 안 정해 건설품셈 참고자료 값으로 섰습니다 — 구조물 상세 입력에서 고르면 산림품셈 계수로 바뀝니다" + }, + "not_here": { + "note": "이 표가 정하지 않는 것.", + "items": [ + "돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.", + "전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다." + ] + }, + "back_lengths_cm": [ + 25, + 30, + 35, + 45, + 55, + 60, + 75 + ], + "no_folding": "⚠ 표에 없는 뒷길이를 **가까운 칸으로 접지 않는다.** 접으면 40㎝ 가 45㎝ 계수로 조용히 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). 표에 없으면 **계수가 없다고 드러낸다.**" +} diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json new file mode 100644 index 00000000..ce84dbd3 --- /dev/null +++ b/resources/data_material_surcharge/_manifest.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "dataset_id": "data_material_surcharge_manifest", + "generated_at": "2026-09-07T00:00:00+09:00", + "built_by": "수작업 — 산림품셈 1-3-1 전사 + 건설품셈 1-3-1 대조(2026-09-07)", + "files": [ + { + "file": "material_surcharge_2026-01-01.json", + "sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc", + "size_bytes": 4724 + } + ] +} diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json new file mode 100644 index 00000000..3451db13 --- /dev/null +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -0,0 +1,168 @@ +{ + "schema_version": "1.0", + "dataset_id": "material_surcharge", + "effective_date": "2026-01-01", + "source": { + "primary": { + "doc": "산림사업 표준품셈 제1장 1-3-1 재료의 할증", + "via": "resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md" + }, + "supplementary": { + "doc": "건설공사 표준품셈 1-3-1 재료의 할증('23년 보완)", + "via": "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제1장_적용기준.md", + "note": "산림품셈에 없는 자재만 보완용으로 본다(PLAN 8-5). 실린 값은 출처를 pumsem 필드로 구분." + } + }, + "policy": { + "no_invented_values": true, + "unknown_material_is_flagged": true + }, + "rates_pct": [ + { + "material": "시멘트", + "rate": 2, + "condition": "정치식", + "alt_rate": 3, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "잔골재", + "rate": 10, + "alt_rate": 12, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "채움재", + "rate": 10, + "alt_rate": 12, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "굵은골재", + "rate": 3, + "alt_rate": 5, + "alt_condition": "기타", + "pumsem": "forest" + }, + { + "material": "모래", + "rate": 6, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "부순돌", + "rate": 4, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "자갈", + "rate": 4, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "점질토", + "rate": 6, + "condition": "노반재료", + "pumsem": "forest" + }, + { + "material": "이형철근", + "rate": 3, + "alt_rate": 7, + "alt_condition": "복잡 구조물 주철근", + "pumsem": "forest" + }, + { + "material": "원형철근", + "rate": 5, + "pumsem": "forest" + }, + { + "material": "강판", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "각재", + "rate": 5, + "pumsem": "forest" + }, + { + "material": "판재", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "레미콘", + "rate": 2, + "condition": "무근", + "alt_rate": 1, + "alt_condition": "철근", + "pumsem": "forest" + }, + { + "material": "흄관", + "rate": 3, + "pumsem": "forest" + }, + { + "material": "떼", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "초화류", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "사방용 수목", + "rate": 10, + "pumsem": "forest" + }, + { + "material": "원석", + "rate": 30, + "condition": "마름돌용", + "pumsem": "forest" + } + ], + "observed_practice": { + "note": "울진 총괄집계 관측 — 참고이지 기본값이 아니다(PLAN 8-10 ★ 법대로).", + "values": { + "모래": 10, + "자갈": 3, + "혼합석": 2, + "시멘트": 2, + "떼": 10 + } + }, + "candidates_pending_user": { + "note": "건설품셈에서 이름은 찾았으나 **적용 조건이 우리 쓰임과 다른** 것. 지식DB는 근거·후보 가이드이지 값을 확정하는 곳이 아니므로(CLAUDE.md 3장) 엔진은 이 값을 쓰지 않고 「할증률 미확보」로 둔다. 사용자 확정 후 rates_pct 로 옮길 것.", + "items": [ + { + "material": "막자갈", + "rate": 4, + "pumsem": "const", + "listed_condition": "노상 및 노반재료(선택층·보조기층·기층)", + "our_usage": "돌쌓기 뒤채움", + "why_not_applied": "조건이 노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음" + } + ] + }, + "not_found": { + "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", + "materials": ["야면석", "고임돌", "물구멍관"], + "checked": [ + "산림품셈 1-3-1 전 19종", + "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", + "건설품셈 제7장 돌공사 — 재료 할증률표 없음" + ] + } +} diff --git a/resources/data_rebar/rebar_complexity_2026-01-01.json b/resources/data_rebar/rebar_complexity_2026-01-01.json new file mode 100644 index 00000000..acdced1a --- /dev/null +++ b/resources/data_rebar/rebar_complexity_2026-01-01.json @@ -0,0 +1,70 @@ +{ + "schema_version": "1.0", + "dataset_id": "rebar_complexity", + "effective_date": "2026-01-01", + "note": "철근 가공·조립 갈래(간단/보통/복잡/매우복잡) — **품셈 12-3 [주]① 원문**이 구조물을 예시로 갈라 둔다. 사람이 고르는 값이 아니라 법으로 정해지는 값이다(거푸집 사용횟수 1-7-1 과 같은 자리).", + "source": { + "doc": "산림사업 표준품셈 12-3 철근 현장가공 및 조립 [주] ①", + "table_id": "F0335", + "quote": "간단한 것이란 측구, 간단한 기초 및 중력식 옹벽 등을 말하며, 보통의 것이란 수문, 반중력식 옹벽 및 교대 등을 말하고, 복잡한 것이란 교량의 슬래브, 암거, 우물통 부벽식 옹벽 등을 말하며, 매우 복잡한 것이란 구주식(기둥형) 교대, 교각, 지하철, 터널" + }, + "policy": { + "auto_decided": true, + "unlisted_is_flagged": true, + "note": "원문 예시에 걸리는 것만 정한다. 안 걸리면 「갈래 미확보」로 드러내고 임의로 고르지 않는다." + }, + "classes": [ + { "key": "간단", "examples": ["측구", "간단한 기초", "중력식 옹벽"] }, + { "key": "보통", "examples": ["수문", "반중력식 옹벽", "교대"] }, + { "key": "복잡", "examples": ["교량 슬래브", "암거", "우물통", "부벽식 옹벽"] }, + { "key": "매우복잡", "examples": ["구주식(기둥형) 교대", "교각", "지하철", "터널"] } + ], + "form_map": [ + { + "type_id": "retaining_wall", + "form": "중력식", + "class": "간단", + "matched": "중력식 옹벽" + }, + { + "type_id": "retaining_wall", + "form": "반중력식", + "class": "보통", + "matched": "반중력식 옹벽" + }, + { + "type_id": "retaining_wall", + "form": "부벽식", + "class": "복잡", + "matched": "부벽식 옹벽" + }, + { + "type_id": "retaining_wall", + "form": "캔틸레버식", + "class": null, + "why": "원문 [주]① 예시에 캔틸레버식 옹벽이 없음 — 임의로 고르지 않고 미확보로 드러냄" + }, + { + "type_id": "box_culvert", + "class": "복잡", + "matched": "암거" + }, + { + "type_id": "pipe_inlet_basin", + "class": "간단", + "matched": "간단한 기초", + "note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요" + } + ], + "price_hint_krw_per_ton": { + "note": "⚠ **표시 전용.** 갈래를 자동으로 정하더라도 화면에 갈래 이름과 차이를 보여야 사람이 검증할 수 있다. B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).", + "computed_by": "B09", + "computed_on": "2026-09-07", + "values": { + "간단": 919146.7, + "보통": 1032497.4, + "복잡": 1143569.7, + "매우복잡": 1278375.3 + } + } +} diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json new file mode 100644 index 00000000..4c48dd52 --- /dev/null +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -0,0 +1,350 @@ +{ + "schema_version": "1.0", + "dataset_id": "structure_unit_observed", + "effective_date": "2026-01-01", + "note": "콘크리트 구조물의 **관측** 원단위표. 품셈에는 구조물별 표준 물량표가 없어(구조물_수량.md · 배수공_수량.md §2) 실무 설계원본에서 뽑은 값이다.", + "policy": { + "basis": "observed", + "no_interpolation": true, + "no_invented_dimensions": true, + "notes": [ + "⚠ 관측값은 **그 규격에서만** 맞다. 규격이 다르면 비례로 늘리지 않는다 — 벽 두께·기초는 높이에 비례하지 않는다.", + "⚠ 규격이 표에 없으면 「원단위 미확보」로 드러낸다. 가까운 값을 갖다 쓰지 않는다.", + "⚠ 줄마다 basis 를 싣는다 — 치수에서 나온 값(derived)과 한 표에 섞이기 때문이다." + ] + }, + "sources": { + "uljin_library": { + "doc": "울진소광 구조도 숨김탭 원단위 라이브러리", + "path": "resources/knowledge/original/실무문서/_원단위라이브러리_울진소광.md" + }, + "uljin_compare": { + "doc": "종합비교 04 — 임도 구조물 원단위 (울진 1공구 수량집계표 관측)", + "path": "resources/knowledge/original/실무문서/_종합비교/04_임도구조물_원단위.md" + } + }, + "entries": [ + { + "type_id": "retaining_wall", + "spec": { + "form": "반중력식", + "height_m": 2.0 + }, + "unit": "m", + "source": "uljin_library", + "source_note": "§7 옹벽류 — 반중력식옹벽 H=2.0", + "components": [ + { + "name": "콘크리트", + "unit": "㎥", + "amount": 1.35, + "destination": "unit_price", + "basis_note": "기초 0.75 + 벽체 0.60" + }, + { + "name": "버림콘크리트", + "unit": "㎥", + "amount": 0.15, + "destination": "unit_price" + }, + { + "name": "유로폼", + "unit": "㎡", + "amount": 3.2, + "destination": "unit_price", + "basis_note": "배면+전면" + }, + { + "name": "합판거푸집", + "unit": "㎡", + "amount": 0.6, + "destination": "unit_price", + "basis_note": "기초" + }, + { + "name": "물구멍관", + "unit": "m", + "amount": 0.32, + "destination": "material", + "basis_note": "Ø50" + }, + { + "name": "이형철근 D13", + "unit": "kg", + "amount": 13.45, + "destination": "material" + }, + { + "name": "이형철근 D16", + "unit": "kg", + "amount": 30.42, + "destination": "material" + } + ] + }, + { + "type_id": "pipe_inlet_basin", + "spec": { + "inlet_basin_form": "돌집수정 ㄷ형" + }, + "unit": "개소", + "source": "uljin_compare", + "source_note": "관보호공 돌집수정 ㄷ형 /개소", + "components": [ + { + "name": "콘크리트", + "unit": "㎥", + "amount": 4.03, + "destination": "unit_price" + }, + { + "name": "모르터", + "unit": "㎥", + "amount": 0.157, + "destination": "unit_price" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 21.1, + "destination": "earthwork", + "basis_note": "토사 14.8 + 암 6.3 — 지반 구분은 토공집계가 다시 가름" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 2.6, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 18.5, + "destination": "earthwork" + } + ] + }, + { + "type_id": "pipe_inlet_basin", + "spec": { + "inlet_basin_form": "돌집수정 ㄴ형" + }, + "unit": "개소", + "source": "uljin_compare", + "source_note": "관보호공 돌집수정 ㄴ형 /개소", + "components": [ + { + "name": "콘크리트", + "unit": "㎥", + "amount": 2.69, + "destination": "unit_price" + }, + { + "name": "모르터", + "unit": "㎥", + "amount": 0.096, + "destination": "unit_price" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 16.4, + "destination": "earthwork", + "basis_note": "토사 4.9 + 암 11.5" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 1.2, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 15.2, + "destination": "earthwork" + } + ] + }, + { + "type_id": "pipe_inlet_basin", + "spec": { + "inlet_basin_form": "□형(기본형)", + "inlet_basin_material": "콘크리트", + "pipe_diameter_mm": "800" + }, + "unit": "개소", + "source": "uljin_library", + "source_note": "§2 집수정 Ø800 — 내부 3.0×1.0×1.2, 벽 0.2, 바닥기초 3.4×1.4×0.2", + "components": [ + { + "name": "콘크리트", + "unit": "㎥", + "amount": 2.84, + "destination": "unit_price" + }, + { + "name": "합판거푸집", + "unit": "㎡", + "amount": 21.28, + "destination": "unit_price" + }, + { + "name": "이형철근 D13", + "unit": "kg", + "amount": 4.78, + "destination": "material" + }, + { + "name": "면목", + "unit": "m", + "amount": 12.67, + "destination": "material", + "basis_note": "A25" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 10.64, + "destination": "earthwork" + }, + { + "name": "되메우기", + "unit": "㎥", + "amount": 6.44, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 4.2, + "destination": "earthwork" + } + ] + }, + { + "type_id": "ford_pavement", + "spec": { + "thickness_cm": 20 + }, + "unit": "㎡", + "source": "uljin_compare", + "source_note": "콘크리트포장 T=20cm /㎡", + "components": [ + { + "name": "레미콘", + "unit": "㎥", + "amount": 0.2, + "destination": "unit_price" + }, + { + "name": "와이어메쉬", + "unit": "㎡", + "amount": 1.16, + "destination": "material" + }, + { + "name": "터파기", + "unit": "㎥", + "amount": 0.2, + "destination": "earthwork" + }, + { + "name": "잔토처리", + "unit": "㎥", + "amount": 0.2, + "destination": "earthwork" + } + ] + } + ], + "not_found": { + "note": "규격은 우리 모델에 있으나 **관측 원단위가 어디에도 없는** 것. 지어내지 않는다.", + "items": [ + { + "type_id": "box_culvert", + "why": "울진 2공구에 BOX암거가 실재하나 원단위 라이브러리에 탭이 없음. 게다가 structures.json 의 BOX 제원은 body_width_m·body_height_m 와 날개벽뿐이라 **벽·저판·상판 두께가 없어 전개식도 못 세움**.", + "needs": "표준 단면(벽·저판·상판 두께) 확보 — 사용자 확정 대기" + }, + { + "type_id": "ford_bridge", + "why": "세월교 본체(날개벽 포함) 원단위 없음. 관 부분은 pipe 로 따로 섬.", + "needs": "표준도 물량 또는 실무 관측" + }, + { + "type_id": "retaining_wall", + "spec": { + "form": "반중력식", + "height_m": 1.6 + }, + "why": "울진 2공구에 H=1.6 이 실재하나 수치가 라이브러리에 없음. H=2.0 값을 비례로 줄이지 않음 — 기초·벽체는 높이에 비례하지 않음." + }, + { + "type_id": "retaining_wall", + "about": "기초잡석", + "why": "⚠ **우리 표가 빠뜨린 것이 아니라 원문에 없음**(2026-09-08 전수 확인). 울진 라이브러리 §7 「반중력식옹벽 H=2.0」 원문은 「콘크리트 1.35㎥(기초 0.75+벽체 0.60) + 버림 0.15㎥, 유로폼 3.20㎡, 기초 거푸집 0.6㎡, 물빼기 파이프 Ø50 0.32m, 철근 D13 13.45㎏ + D16 30.42㎏」이 전부다 — 기초잡석·터파기·되메우기·잔토가 다 없다.", + "note": "기초잡석이 있는 시트는 **§1 관보호공 날개벽**뿐이다(T=0.2 · Ø800 A-TYPE 1.15㎥/개소 등). 옹벽에 그 값을 옮겨 쓰면 **다른 구조물 값을 갖다 쓰는 것**이라 하지 않는다.", + "needs": "옹벽 기초잡석을 계상할지 · 하면 두께를 얼마로 볼지 — 사용자 확정. 품셈 12-25 는 「기초잡석 운반·부설·다짐」 품만 주고 **두께를 정하지 않는다**(㎥당)." + } + ] + }, + "double_count_rules": { + "note": "이 표의 값이 품셈 다른 자리와 겹치는 곳. 겹치면 한쪽만 쓴다.", + "rules": [ + { + "key": "물빼기 파이프 ↔ 제잡비 윗단", + "where": "품셈 13-6-2·13-6-3·13-7-2 [주]③", + "quote": "물빼기 파이프를 설치한 경우는 윗단의 값, 설치하지 않는 경우는 아랫단의 값으로 하며, 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다.", + "our_choice": "물구멍(관)을 **자재로 명시해 세고**, 큰돌쌓기·큰돌붙이기를 쓸 때는 **제잡비 아랫단(미설치)** 을 쓴다.", + "why": "물구멍을 자재 줄로 세우면 규격·수량이 눈에 보이고 되짚을 수 있다. 제잡비 윗단은 같은 것을 품 안에 녹이는 다른 방식이라 어느 쪽이든 하나만 골라야 한다.", + "scope": "⚠ 지금 쓰는 돌쌓기(13-4 계열)에는 **제잡비 행 자체가 없어** 겹치지 않는다(전수 확인). 이 규칙은 13-6·13-7 을 쓰게 될 때 걸린다.", + "guard": "제잡비 윗단과 물구멍 줄이 함께 서면 멈출 것 — B09 ㉥ 가드와 짝." + } + ] + }, + "原文_뒷받침": { + "note": "관측값이 **원문과 맞는 것이 확인된** 항목. 지금까지 관측값은 근거가 약한 참조였는데 이 줄은 원문 뒷받침이 있다.", + "items": [ + { + "item": "채움콘크리트 0.2 ㎥/㎡", + "observed": "울진 라이브러리 돌쌓기(찰) 채움 0.2 ㎥/㎡", + "source": "품셈 13-6-2 [주]⑩ 「큰돌쌓기(찰쌓기)의 뒤채움콘크리트량은 0.2㎥기준으로 하고 현지여건에 따라 0.3㎥까지 적용할 수 있다」" + } + ] + }, + "pending_choices": { + "note": "값을 바꾸는 **설계 조건**인데 우리 제원에 칸이 없는 것. 기본값과 「정하면 얼마나 달라지는지」를 함께 적어 화면이 보이게 한다.", + "items": [ + { + "key": "material_basis_area", + "label": "자재 원단위의 밑수 — 비탈면적인가 정면적인가", + "default": "비탈면적(= 돌쌓기 면적)", + "where": "실무 원단위 라이브러리 두 시트가 다르게 씀 — 돌골막이는 「(돌쌓기+돌붙임)×0.88 ton/㎡」로 **돌쌓기 면적**에, 돌기슭막이는 고임돌 0.15㎥/㎡ × **정면적** 1.5 = 0.225(시트 0.23)로 씀", + "effect": "정면적으로 바꾸면 고임돌·야면석·채움콘크리트·모르터가 **−4.4 %**", + "scope": "⚠ 면적 자체는 닫혔음(2026-09-08) — 돌쌓기 면적 = 정면적 × √(1+n²), 기울기 몫은 한 번만. 남은 물음은 **그 다음 원단위를 어느 면적에 곱하나**임" + }, + { + "key": "anti_suction_sheet", + "label": "흡출방지재·차수시트 시공", + "default": false, + "where": "품셈 13-6·13-7 [주]② — 「흡출방지재 또는 차수시트를 시공하는 경우는 ( )의 값을 적용한다」", + "effect": "인부 수량이 갈림 — 큰돌쌓기 메쌓기 직경 40~60㎝ 기준 보통인부 1.04 → 1.17 인/10㎡ (약 +12.5 %)", + "scope": "⚠ 큰돌쌓기(13-6)·큰돌붙이기(13-7)에만 걸린다. 지금 쓰는 돌쌓기(13-4)에는 괄호 값 자체가 없다." + }, + { + "key": "timber_crib_unit", + "label": "목재틀흙막이 원단위", + "default": "품셈 13-13-1 그대로", + "where": "품셈 13-13-1 (단위: 인/㎥당)", + "effect": "1㎥당 건축목공 16.975인 + 보통인부 1.848인. ⚠ 실무 감각에 맞는지 아무도 판단 못 했고, 각재·판재 자재가 카탈로그에 없어 **지금 값은 모자란 값**이다." + }, + { + "key": "bill_quantity_digits", + "label": "내역서 수량 표시 자릿수", + "default": null, + "where": "`단수처리_규칙.md` 에 **금액 자리만 있고 수량 자리가 없음**", + "effect": "표시 자릿수와 계산 자릿수가 다르면 보는 사람이 반올림해 곱해 보고 「틀렸다」고 한다(2026-09-07 실제로 그렇게 오진한 일이 있었다)." + } + ] + } +} diff --git a/resources/data_timber/timber_structure_class_2026-01-01.json b/resources/data_timber/timber_structure_class_2026-01-01.json new file mode 100644 index 00000000..a62ec24f --- /dev/null +++ b/resources/data_timber/timber_structure_class_2026-01-01.json @@ -0,0 +1,80 @@ +{ + "schema_version": "1.0", + "dataset_id": "timber_structure_class", + "effective_date": "2026-01-01", + "note": "목재공작물 구조 갈래(품셈 13-13-1 [주]③). **재료 구성**으로 가르며 원문이 예시를 든다. 구조물 종류가 늘면 `type_map` 에 줄만 더한다.", + "source": { + "doc": "산림사업 표준품셈 13-13-1 목재틀흙막이 [주]③", + "table_id": "F0440", + "quote": "보통구조 : 통나무나 대각재, 후판 등이 대부분(80%) 이상으로, 목재 채적에 비해 가공정도가 적은 공작물 (하: 통나무 경계목 / 중: 통나무 방풍책 / 상: 통나무 기슭막이) · 중등구조 : 통나무나 대각재, 후판 등이 절반(50%) 이상 (보통: 통나무 바닥막이·누구막이 / 상: 통나무 골막이) · 상등구조 : 소각재, 박판, 소폭판 등이 목재의 50% 이상 (통나무 사방댐 등)" + }, + "basis_unit": { + "note": "⚠ 밑수 1㎥ 는 **구조물 체적이 아니라 목재 채적**이다. 원문이 「목재 채적에 비해 가공정도」로 갈래를 가르는 데서 드러난다. 그래야 「1㎥에 건축목공 17인」이 말이 된다.", + "unit": "목재 채적 ㎥" + }, + "classes": [ + { + "key": "보통구조 하", + "carpenter": 6.285, + "laborer": 0.682, + "examples": [ + "통나무 경계목" + ] + }, + { + "key": "보통구조 중", + "carpenter": 7.274, + "laborer": 0.786, + "examples": [ + "통나무 방풍책" + ] + }, + { + "key": "보통구조 상", + "carpenter": 8.76, + "laborer": 0.958, + "examples": [ + "통나무 기슭막이" + ] + }, + { + "key": "중등구조 보통", + "carpenter": 10.612, + "laborer": 1.156, + "examples": [ + "통나무 바닥막이", + "누구막이" + ] + }, + { + "key": "중등구조 상", + "carpenter": 13.767, + "laborer": 1.497, + "examples": [ + "통나무 골막이" + ] + }, + { + "key": "상등구조", + "carpenter": 16.975, + "laborer": 1.848, + "examples": [ + "통나무 사방댐" + ] + } + ], + "type_map": [ + { + "type_id": "soil_guard", + "class": "보통구조 상", + "matched": "통나무 기슭막이", + "provisional": true, + "why": "임도 흙막이는 통나무를 짜 맞춘 틀이고 **소각재·박판·소폭판이 목재의 50 % 이상**이라는 상등구조 조건을 안 채운다. 원문 예시로도 「통나무 기슭막이」가 같은 급이고 「통나무 사방댐」은 사방 구조물이다.", + "compare": "상등구조를 쓰면 건축목공 8.760 → 16.975 인/㎥ 로 약 1.9배" + } + ], + "pending_user": { + "note": "⚠ 위 판정은 **잠정**이다. 사용자가 예/아니오로 답할 수 있게 물음을 좁혀 둔다.", + "question": "임도 흙막이의 목재공작물 구조 갈래가 「보통구조 상(통나무 기슭막이 급)」이 맞습니까?" + } +} diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json new file mode 100644 index 00000000..61f3a127 --- /dev/null +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -0,0 +1,365 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_mapping", + "effective_date": "2026-01-01", + "note": "B08 이 낸 수량 줄을 공종 마스터 코드(FP-…)에 잇는 다리. 발주처 골격이 바뀌어도 코드를 안 고치게 데이터로 둔다.", + "master": { + "dataset_id": "work_item_master", + "effective_date": "2026-01-01" + }, + "policy": { + "no_invented_codes": true, + "unmatched_is_listed": true, + "note": "못 이은 줄은 빈 코드로 두지 않고 unmatched_work_items 로 낸다. 빈칸이면 없어진 것과 구별이 안 된다." + }, + "earthwork": [ + { + "group": "흙깎기", + "ground": "토사", + "work_item_code": "FP-09-03-02", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4704 「Q=3600×q×k×f×E/㎝= ㎥/hr」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "토사깍기 > 기계", + "note": "인력 시공이면 FP-09-03-01" + }, + { + "group": "흙깎기", + "ground": "리핑암", + "work_item_code": "FP-09-04", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4725·L4738 「Q(시간당 작업량 ㎥/hr)」·「Q=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "암절취" + }, + { + "group": "흙깎기", + "ground": "발파암", + "work_item_code": "FP-09-05", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4744 「(단위: ㎥당)」 · L4777 「Q=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "발파암" + }, + { + "group": "측구터파기", + "ground": "토사", + "work_item_code": "FP-09-12-01", + "master_name": "측구터파기 > 토사" + }, + { + "group": "측구터파기", + "ground": "리핑암", + "work_item_code": "FP-09-12-02", + "master_name": "측구터파기 > 암절취" + }, + { + "group": "측구터파기", + "ground": "발파암", + "work_item_code": "FP-09-12-03", + "master_name": "측구터파기 > 발파암" + }, + { + "group": "성토", + "work_item_code": "FP-09-16", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5322·L5336·L5363 세 하위 모두 「= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "노체", + "note": "포설(FP-09-16-01)·다짐(FP-09-16-02)로 갈리는 자리 — 내역 양식이 정해지면 내린다" + }, + { + "group": "성토면다짐", + "work_item_code": "FP-09-17-01", + "basis_unit": "㎡", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5368 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "다짐 > 비탈면 다짐" + }, + { + "group": "층따기", + "work_item_code": "FP-09-18", + "master_name": "층따기", + "basis_unit": "㎥", + "basis_source": "품셈 9-18 [주] 「Q1 = 3600×q×K×f×E/㎝ = ㎥/시간」 — 절 머리에 「(단위: …)」가 없고 **공식으로만** 단위가 밝혀지는 자리라 마스터 `basis_unit` 이 비어 있다(2026-09-08 B09 확인).", + "mismatch_reason": "층따기는 품셈이 **체적(㎥)**으로 세는데 우리 집계는 **성토 비탈면적(㎡)** 입니다 — 층따기 단의 높이·폭이 있어야 체적이 나옵니다(교본: 「층따기 높이·폭은 설계도서에 명시」). 그 값이 정해지면 물량이 섭니다.", + "mismatch_kind": "input_missing" + }, + { + "group": "면고르기", + "ground": "토사", + "work_item_code": "FP-09-19-01", + "master_name": "면고르기 > 토사면 고르기" + }, + { + "group": "면고르기", + "ground": "리핑암", + "work_item_code": "FP-09-19-02", + "master_name": "면고르기 > 비탈면 면고르기(암절취)" + }, + { + "group": "면고르기", + "ground": "발파암", + "work_item_code": "FP-09-19-03", + "master_name": "면고르기 > 비탈면 면고르기(발파암)" + }, + { + "group": "초류종자살포", + "work_item_code": "FP-05-24", + "master_name": "씨앗뿜어붙이기", + "basis_unit": "㎡", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조)." + }, + { + "group": "되메우기", + "work_item_code": "FP-09-14-01", + "master_name": "되메우기 및 다짐 > 되메우기" + } + ], + "haul": [ + { + "equipment": "dozer", + "work_item_code": "FP-10-11", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5919 「Q=60×q×f×E/㎝=㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "불도저 운반" + }, + { + "equipment": "dump_truck", + "payload_density_note": "⚠ 적재 재료의 **단위중량 γt 가 원문에 있다** — L5930 [주]② 「적재 재료의 단위중량은 **토사 1.9ton/㎥, 암절취 및 발파암은 2.4ton/㎥** 적용한다」. 앞서 「현장값이라 미확보」로 두었던 자리가 원문으로 풀렸다(2026-09-08). ⚠ **갈래별로 값이 다르므로** `ground_class` 를 함께 봐야 한다. 남은 것은 운반거리 L(설계에서 옴)과 덤프 적재용량 T 둘뿐이다.", + "work_item_code": "FP-10-12", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5928·L5971·L5985 세 하위 모두 「Q1=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", + "master_name": "덤프 운반" + }, + { + "equipment": "free_haul", + "work_item_code": null, + "in_bill": false, + "reason": "무대(소운반 20m 이내)는 품에 포함 — 내역 줄이 아니다(품셈 1-2-7, ㉡)" + } + ], + "structure": [ + { + "type_id": "masonry_wet", + "secondary_axes": ["stone_kind"], + "secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).", + "billing_component": "돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.", + "work_item_code": "FP-13-04-05", + "master_name": "돌쌓기 > 찰쌓기(장비)", + "note": "인력 시공이면 FP-13-04-04", + "class_note": "⚠ 갈래 키 문자열을 여기서 만들지 않는다 — `variant_axis`+`variant_value`(저장 원본값)만 보내고 「…㎝ 이하」 구간 나누기는 **품셈 원문을 읽는 쪽**이 한다(2026-09-07 계약).", + "variant_axis": "back_len_cm" + }, + { + "type_id": "masonry_dry", + "secondary_axes": ["stone_kind"], + "secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).", + "billing_component": "돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.", + "work_item_code": "FP-13-04-02", + "master_name": "돌쌓기 > 메쌓기(장비)", + "note": "인력 시공이면 FP-13-04-01", + "class_note": "⚠ 갈래 키 문자열을 여기서 만들지 않는다 — `variant_axis`+`variant_value`(저장 원본값)만 보내고 「…㎝ 이하」 구간 나누기는 **품셈 원문을 읽는 쪽**이 한다(2026-09-07 계약).", + "variant_axis": "back_len_cm" + }, + { + "type_id": "pipe_inlet_basin", + "work_item_code": "FP-12-15", + "master_name": "집수정" + }, + { + "type_id": "ford_pavement", + "work_item_code": "FP-12-06", + "master_name": "콘크리트 포장(인력시공)", + "basis_unit_note": "⚠ 원문 L6229 는 「(단위: **일당**)」인데 그것은 **품의 단위**(하루에 얼마)이지 공종 수량 단위가 아니다 — 밑수로 받지 않는다. 작업조형 표라 시공량으로 나눠야 수량 단위가 나온다(2026-09-08 B09 확인). 밑수가 설 때까지 대조 대상이 아니다." + }, + { + "type_id": "boulder_masonry", + "billing_component": "큰돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다. ⚠ 13-6 은 밑수가 **10㎡** 다 — 받는 쪽이 마스터의 `basis_quantity` 를 함께 봐야 한다.", + "work_item_code": null, + "class_from": "bond", + "bond_codes": { + "메쌓기": "FP-13-06-01", + "찰쌓기": "FP-13-06-02" + }, + "class_note": "메/찰(`bond`)은 **공종 자체가 갈리는 의미 판정**이라 여기서 고른다. 직경 갈래는 `variant_value` 로 원본값만 보낸다 — 원문이 물결표를 섞어 써서.", + "variant_axis": "stone_cm" + }, + { + "type_id": "ditch_ridge", + "work_item_code": "FP-12-09-02", + "master_name": "측구 > 산마루 측구", + "note": "품셈 12-9-2 · 밑수 1 m — 구조물 연장(m)이 그대로 수량이다. 2026-09-08 V-3 확인에서 이었다(그전에는 매핑이 없어 연장은 오는데 공종코드가 비어 있었다)." + }, + { + "type_id": "ditch_berm", + "work_item_code": "FP-12-09-03", + "master_name": "측구 > 소단 측구", + "note": "품셈 12-9-3 · 밑수 1 m" + }, + { + "type_id": "underdrain", + "work_item_code": "FP-12-10", + "master_name": "맹암거", + "note": "품셈 12-10 · 밑수 1 m" + } + ], + "pending_user": { + "note": "이름이 비슷한 후보는 있으나 **어느 것인지 정할 근거가 없는** 자리. 임의로 고르지 않고 unmatched 로 낸다(CLAUDE.md 3장).", + "items": [ + { + "group": "지장목제거", + "candidates": [ + "FP-04-01 수확베기", + "FP-04-02 단목베기", + "FP-04-03 위험목 베기" + ], + "why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음" + }, + { + "group": "흙깎기/측구터파기 암", + "candidates": [ + "FP-09-04 암절취(리핑)", + "FP-09-05 발파암" + ], + "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" + }, + { + "type_id": "chute", + "name": "도수로·산비탈수로", + "why": "품셈 12장 측구 계열(12-9-1 L형 · 12-9-2 산마루 · 12-9-3 소단)과 12-10 맹암거에 **해당 공종이 없음**(2026-09-08 마스터 전수). 「수로」로 검색해도 나오는 것은 그 셋뿐임.", + "needs": "어느 공종으로 볼지 사용자 확정 — 또는 별도 표준도·일위대가" + }, + { + "type_id": "slope_drain", + "name": "절토사면 배수로", + "why": "위와 같음 — 품셈에 그 이름의 공종이 없음.", + "needs": "어느 공종으로 볼지 사용자 확정" + } + ] + }, + "composite": { + "note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.", + "items": [ + { + "type_id": "retaining_wall", + "parts": [ + { + "code": "FP-12-01-01", + "name": "콘크리트 타설", + "unit": "㎥", + "from_components": [ + "콘크리트", + "버림콘크리트" + ], + "kind_suffix": "concrete_placing" + }, + { + "code": "FP-12-04", + "name": "합판거푸집", + "unit": "㎡", + "from_components": [ + "합판거푸집" + ] + }, + { + "code": "FP-12-38", + "name": "유로폼", + "unit": "㎡", + "from_components": [ + "유로폼" + ], + "kind_suffix": "euroform_type", + "incomplete_note": "⚠ 자재 몫이 빠져 있음 — 12-38-2 의 패널 0.89매/10㎡·부자재(주자재비의 24/52/79 %)·소모자재 5 % 는 **자재 단가가 서야** 붙는다. 지금 서는 것은 설치·해체 품뿐이다." + }, + { + "code": "FP-12-03", + "name": "철근 현장가공 및 조립", + "unit": "ton", + "from_components": [ + "이형철근 D13", + "이형철근 D16" + ], + "unit_from": "kg", + "kind_suffix": "rebar_complexity" + }, + { + "code": "FP-12-25", + "name": "기초잡석", + "unit": "㎥", + "from_components": [], + "not_ready": true, + "why": "관측 원단위에 기초잡석 물량이 없음 — 울진 라이브러리 반중력식 H=2.0 항목에 그 줄이 없다" + } + ], + "why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.", + "needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김", + "placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.", + "parts_note": "조각마다 **어느 성분에서 나오는지**(`from_components`)를 적는다. 이름을 바꾸면 물량이 조용히 0 이 되므로 원단위 성분 이름과 정확히 같아야 하고, 못 찾으면 드러낸다. `unit_from` 이 있으면 그 단위에서 목표 단위로 환산한다 — **철근은 ㎏ → ton (÷1000)**. ⚠ 단위를 안 맞추면 1000배 틀린다." + } + ], + "unit_conversion": { + "note": "조각 단위 환산. 단가의 단위(원/ton)와 원단위의 단위(㎏)가 달라 반드시 맞춰야 한다.", + "kg_to_ton": 0.001 + } + }, + "concrete_placing": { + "note": "콘크리트 타설은 **타설 방식 × 구조물 종류**로 갈린다. 방식은 설계 판단이라 프로젝트 설정(`quantity.concrete_placing_method`)이 고르고, 종류는 **원단위에 철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(2026-09-07 3자 확정).", + "method_codes": { + "ready_mixed": "FP-12-01-01", + "machine_mixed": "FP-12-01-02", + "hand_mixed": "FP-12-01-03" + }, + "default_method": "ready_mixed", + "default_is_provisional": true, + "structure_kinds": [ + "무근구조물", + "철근구조물", + "소형구조물" + ], + "kind_rule": "원단위 성분에 철근(이형철근·원형철근)이 있으면 철근구조물, 없으면 무근구조물. 소형구조물 판정 기준은 미확보.", + "price_hint_krw_per_m3": { + "note": "⚠ **표시 전용.** 사용자가 타설 방식을 고를 때 「정하면 얼마나 달라지는지」를 보이려고 둔 값이며 B08 의 어떤 계산에도 들어가지 않는다(금액은 B09 몫 — 8-2 경계). 값은 B09 가 2026-09-07 에 낸 철근구조물 기준 단가이고, 요율·노임이 바뀌면 어긋난다 — 화면이 「참고」임을 함께 적는다.", + "basis": "철근구조물 콘크리트 타설 (원/㎥)", + "computed_by": "B09", + "computed_on": "2026-09-07", + "values": { + "ready_mixed": 65826, + "machine_mixed": 163508, + "hand_mixed": 408327 + } + } + }, + "variant_contract": { + "note": "갈래 계약(2026-09-07 3자) — **키 문자열을 두 창이 각자 조립하지 않는다.**", + "why": "품셈 원문이 물결표를 섞어 쓴다: 13-06-01·02 는 `∼`(U+223C), 13-06-03 은 `~`(U+FF5E). 각자 조립하면 글자 하나로 영영 안 맞는다.", + "b08_sends": [ + "work_item_code", + "variant_axis", + "variant_value", + "kind_basis" + ], + "b09_does": "원문 표기(물결표·공백·괄호)를 흡수해 자기 키로 옮긴다. 「…㎝ 이하」 구간 나누기도 그쪽 몫 — 그 구간이 **품셈 표의 구조**이기 때문." + }, + "masonry_class_reference": "resources/data_masonry/masonry_class_2026-01-01.json — ⚠ 이제 **참고용**이다. 서브 판정과 어긋나면 그것이 곧 신호다.", + "pipe": { + "note": "배수관(횡단배수관) — **관종으로 공종이 갈린다.** 관은 `structures.json` 이 아니라 `pipe_points.json` 이 정본이고(레지스트리 `pipe` 타입이 `managed_by: pipe_points`), 연장은 B06 횡단이 서버 Node 로 계산해 측점 `design.pipe_length_m` 로 남긴다(2026-09-08 랩탑 창). B08 은 그 셋을 잇기만 한다.", + "kind_codes": { + "파형강관": "FP-12-11-03", + "흄관": "FP-12-11-02", + "VR관": "FP-12-11-01" + }, + "kind_option_key": "pipe_kind", + "default_kind": "파형강관", + "default_is_user_confirmed": true, + "default_note": "레지스트리 `pipe` 옵션의 기본값이며 **2026-08-17 사용자 확정**임. 그래도 저장값이 비어 있으면 「관종 미지정」으로 드러내고 기본값으로 돈다는 사실을 함께 싣는다(암 시공법과 같은 처리).", + "unit": "m", + "length_key": "pipe_length_m", + "diameter_option_key": "pipe_diameter_mm", + "variant_axis": "pipe_diameter_mm", + "facility_rule": "⚠ `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이다(배관·BOX암거·물넘이·세월교·독립 기슭막이). `facility` 가 `pipe` 인 점만 배관이다 — `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**(2026-09-08 랩탑 창).", + "revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌으므로 **구조물 쪽으로 또 세지 않는다**(2026-08-28 이관).", + "not_ready": { + "흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.", + "터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다." + } + } +} diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json new file mode 100644 index 00000000..2abebbdb --- /dev/null +++ b/resources/data_work_item_master/_manifest.json @@ -0,0 +1,29 @@ +{ + "schema_version": "1.0", + "dataset_id": "data_work_item_master_manifest", + "generated_at": "2026-09-08T08:33:10+09:00", + "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", + "source": { + "dataset_id": "pum_forest", + "effective_date": "2026-01-01", + "sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd", + "file": "pum_forest_2026.json" + }, + "files": [ + { + "file": "work_item_master_2026-01-01.json", + "sha256": "3752af1e0328a2faf4947268e449e0bc602a0738cc6a29a78ed71d343cf2e583", + "size_bytes": 856832 + }, + { + "file": "form_undetermined_2026-01-01.json", + "sha256": "7334dab9385bc1615a9cdb557ba814482e9a63d83b9e1232946918bfd8b5577f", + "size_bytes": 37139 + }, + { + "file": "basis_missing_2026-01-01.json", + "sha256": "244851609c65ba8adbfc318668224cbf3a81032e9271cfda916de435d460dd92", + "size_bytes": 18360 + } + ] +} \ No newline at end of file diff --git a/resources/data_work_item_master/basis_missing_2026-01-01.json b/resources/data_work_item_master/basis_missing_2026-01-01.json new file mode 100644 index 00000000..a15cf0d1 --- /dev/null +++ b/resources/data_work_item_master/basis_missing_2026-01-01.json @@ -0,0 +1,818 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_master_basis_missing", + "effective_date": "2026-01-01", + "note": "밑수(「10㎡당」 같은 기준 수량)를 못 찾은 표. **1 단위당으로 단정하지 말 것** — 곱셈이 10배·100배 틀린다. 값을 곱해야 하는 형태(requirement·productivity)만 담는다.", + "items": [ + { + "pum_table_id": "F0042", + "section": "2-1-1. 휘발유․오일", + "pum_form": "requirement", + "line": 1305 + }, + { + "pum_table_id": "F0043", + "section": "2-1-1. 휘발유․오일", + "pum_form": "requirement", + "line": 1328 + }, + { + "pum_table_id": "F0049", + "section": "2-1-4. 페인트 및 마킹테이프", + "pum_form": "requirement", + "line": 1402 + }, + { + "pum_table_id": "F0050", + "section": "2-1-4. 페인트 및 마킹테이프", + "pum_form": "requirement", + "line": 1417 + }, + { + "pum_table_id": "F0052", + "section": "2-1-4. 페인트 및 마킹테이프", + "pum_form": "requirement", + "line": 1444 + }, + { + "pum_table_id": "F0057", + "section": "2-1-7. 소각, 매몰, 훈증, 박피", + "pum_form": "requirement", + "line": 1518 + }, + { + "pum_table_id": "F0061", + "section": "2-1-11. 지상 약제살포", + "pum_form": "requirement", + "line": 1570 + }, + { + "pum_table_id": "F0062", + "section": "2-1-12. 그물망 피복", + "pum_form": "requirement", + "line": 1579 + }, + { + "pum_table_id": "F0075", + "section": "3-1. 경계표시", + "pum_form": "requirement", + "line": 1731 + }, + { + "pum_table_id": "F0077", + "section": "3-3. 작업로 설치", + "pum_form": "requirement", + "line": 1754 + }, + { + "pum_table_id": "F0078", + "section": "3-4-3. 임산물 운반로 및 작업로 보수비 산정", + "pum_form": "requirement", + "line": 1815 + }, + { + "pum_table_id": "F0080", + "section": "3-6. 산물 임내정리", + "pum_form": "requirement", + "line": 1867 + }, + { + "pum_table_id": "F0081", + "section": "3-7. 재해산물 수집", + "pum_form": "requirement", + "line": 1887 + }, + { + "pum_table_id": "F0082", + "section": "3-8. 드론 영상 촬영", + "pum_form": "requirement", + "line": 1901 + }, + { + "pum_table_id": "F0083", + "section": "4-1-1. 임업용 동력기계톱", + "pum_form": "productivity", + "line": 1929 + }, + { + "pum_table_id": "F0084", + "section": "4-1-2. 하베스터(부착형 스트로크)", + "pum_form": "productivity", + "line": 1947 + }, + { + "pum_table_id": "F0085", + "section": "4-1-2. 하베스터(부착형 스트로크)", + "pum_form": "requirement", + "line": 1957 + }, + { + "pum_table_id": "F0086", + "section": "4-2-1. 100본당", + "pum_form": "requirement", + "line": 1967 + }, + { + "pum_table_id": "F0087", + "section": "4-2-2. 1,000㎡당", + "pum_form": "requirement", + "line": 1997 + }, + { + "pum_table_id": "F0088", + "section": "4-3. 위험목 베기", + "pum_form": "requirement", + "line": 2012 + }, + { + "pum_table_id": "F0089", + "section": "4-4. 가지정리", + "pum_form": "requirement", + "line": 2041 + }, + { + "pum_table_id": "F0090", + "section": "4-5. 벌도 위험목 점검", + "pum_form": "requirement", + "line": 2052 + }, + { + "pum_table_id": "F0091", + "section": "4-6. 벌목부 작업안전 보조", + "pum_form": "requirement", + "line": 2062 + }, + { + "pum_table_id": "F0092", + "section": "5-1-1. 관목굴취", + "pum_form": "requirement", + "line": 2081 + }, + { + "pum_table_id": "F0093", + "section": "5-1-2. 교목굴취(나무높이)", + "pum_form": "requirement", + "line": 2099 + }, + { + "pum_table_id": "F0094", + "section": "5-1-3. 교목굴취(근원직경)", + "pum_form": "requirement", + "line": 2125 + }, + { + "pum_table_id": "F0095", + "section": "5-1-3. 교목굴취(근원직경)", + "pum_form": "requirement", + "line": 2156 + }, + { + "pum_table_id": "F0097", + "section": "5-1-5. 떼운반 적재 기준표", + "pum_form": "requirement", + "line": 2177 + }, + { + "pum_table_id": "F0098", + "section": "5-2. 뿌리돌림", + "pum_form": "requirement", + "line": 2194 + }, + { + "pum_table_id": "F0099", + "section": "5-3-1. 나무식재", + "pum_form": "requirement", + "line": 2219 + }, + { + "pum_table_id": "F0102", + "section": "5-3-2. 관목식재(단식)", + "pum_form": "requirement", + "line": 2254 + }, + { + "pum_table_id": "F0103", + "section": "5-3-3. 관목식재(군식)", + "pum_form": "requirement", + "line": 2272 + }, + { + "pum_table_id": "F0104", + "section": "5-3-4. 교목식재(나무높이)", + "pum_form": "requirement", + "line": 2291 + }, + { + "pum_table_id": "F0106", + "section": "5-3-5. 교목식재(흉고직경)", + "pum_form": "requirement", + "line": 2322 + }, + { + "pum_table_id": "F0108", + "section": "5-3-5. 교목식재(흉고직경)", + "pum_form": "requirement", + "line": 2355 + }, + { + "pum_table_id": "F0109", + "section": "5-4. 파종조림", + "pum_form": "requirement", + "line": 2366 + }, + { + "pum_table_id": "F0110", + "section": "5-5. 천연하종갱신", + "pum_form": "requirement", + "line": 2383 + }, + { + "pum_table_id": "F0111", + "section": "5-6. 움싹갱신", + "pum_form": "requirement", + "line": 2396 + }, + { + "pum_table_id": "F0112", + "section": "5-7. 생태보완조림", + "pum_form": "requirement", + "line": 2409 + }, + { + "pum_table_id": "F0113", + "section": "5-8. 큰나무 공익조림", + "pum_form": "requirement", + "line": 2426 + }, + { + "pum_table_id": "F0114", + "section": "5-9. 해안조림", + "pum_form": "requirement", + "line": 2439 + }, + { + "pum_table_id": "F0116", + "section": "5-11. 사초심기", + "pum_form": "requirement", + "line": 2475 + }, + { + "pum_table_id": "F0117", + "section": "5-12. 떼붙임(재배잔디)", + "pum_form": "requirement", + "line": 2496 + }, + { + "pum_table_id": "F0118", + "section": "5-13. 떼심기", + "pum_form": "requirement", + "line": 2511 + }, + { + "pum_table_id": "F0121", + "section": "5-16-1. 단끊기", + "pum_form": "requirement", + "line": 2565 + }, + { + "pum_table_id": "F0126", + "section": "5-19-1. 표토절취 및 모으기", + "pum_form": "requirement", + "line": 2653 + }, + { + "pum_table_id": "F0129", + "section": "5-21. 표토이식", + "pum_form": "requirement", + "line": 2689 + }, + { + "pum_table_id": "F0132", + "section": "5-22-4. 평떼 시비", + "pum_form": "requirement", + "line": 2731 + }, + { + "pum_table_id": "F0143", + "section": "5-27. 식재면 관리", + "pum_form": "requirement", + "line": 2905 + }, + { + "pum_table_id": "F0144", + "section": "5-28-1. 짚망", + "pum_form": "requirement", + "line": 2919 + }, + { + "pum_table_id": "F0145", + "section": "5-28-2. 방초매트 및 야자섬유매트 포장", + "pum_form": "requirement", + "line": 2929 + }, + { + "pum_table_id": "F0153", + "section": "6-1. 비료주기", + "pum_form": "requirement", + "line": 3062 + }, + { + "pum_table_id": "F0154", + "section": "6-2-1. 둘레베기", + "pum_form": "requirement", + "line": 3079 + }, + { + "pum_table_id": "F0155", + "section": "6-2-2. 줄베기", + "pum_form": "requirement", + "line": 3089 + }, + { + "pum_table_id": "F0156", + "section": "6-2-3. 모두베기", + "pum_form": "requirement", + "line": 3105 + }, + { + "pum_table_id": "F0157", + "section": "6-3. 맹아제거", + "pum_form": "requirement", + "line": 3122 + }, + { + "pum_table_id": "F0158", + "section": "6-4-1. 덩굴걷기", + "pum_form": "requirement", + "line": 3143 + }, + { + "pum_table_id": "F0159", + "section": "6-4-2. 덩굴 약제 살포처리", + "pum_form": "requirement", + "line": 3153 + }, + { + "pum_table_id": "F0160", + "section": "6-4-3. 소금처리", + "pum_form": "requirement", + "line": 3164 + }, + { + "pum_table_id": "F0161", + "section": "6-4-4. 뿌리제거", + "pum_form": "requirement", + "line": 3183 + }, + { + "pum_table_id": "F0163", + "section": "6-5. 어린나무 가꾸기", + "pum_form": "requirement", + "line": 3244 + }, + { + "pum_table_id": "F0164", + "section": "6-6. 가지치기 및 수형교정", + "pum_form": "requirement", + "line": 3276 + }, + { + "pum_table_id": "F0165", + "section": "6-7-1. 교목 시비", + "pum_form": "requirement", + "line": 3304 + }, + { + "pum_table_id": "F0166", + "section": "6-7-2. 관목 시비", + "pum_form": "requirement", + "line": 3318 + }, + { + "pum_table_id": "F0170", + "section": "7-1-1. 수확", + "pum_form": "requirement", + "line": 3371 + }, + { + "pum_table_id": "F0171", + "section": "7-1-1. 수확", + "pum_form": "requirement", + "line": 3381 + }, + { + "pum_table_id": "F0172", + "section": "7-1-2. 숲가꾸기, 병해충방제", + "pum_form": "requirement", + "line": 3395 + }, + { + "pum_table_id": "F0174", + "section": "7-3. 아키야윈치(임업용 윈치) 집재", + "pum_form": "requirement", + "line": 3439 + }, + { + "pum_table_id": "F0175", + "section": "7-4-1. 수확", + "pum_form": "requirement", + "line": 3452 + }, + { + "pum_table_id": "F0176", + "section": "7-4-2. 숲가꾸기, 산림병해충방제", + "pum_form": "requirement", + "line": 3476 + }, + { + "pum_table_id": "F0177", + "section": "7-5-1. 수확", + "pum_form": "requirement", + "line": 3496 + }, + { + "pum_table_id": "F0178", + "section": "7-5-2. 숲가꾸기, 병해충방제", + "pum_form": "requirement", + "line": 3519 + }, + { + "pum_table_id": "F0179", + "section": "7-6. 스윙야더 집재", + "pum_form": "requirement", + "line": 3542 + }, + { + "pum_table_id": "F0180", + "section": "7-7-1. 수확", + "pum_form": "requirement", + "line": 3556 + }, + { + "pum_table_id": "F0181", + "section": "7-7-2. 숲가꾸기, 병해충방제", + "pum_form": "requirement", + "line": 3580 + }, + { + "pum_table_id": "F0182", + "section": "7-8-1. 가선설치", + "pum_form": "requirement", + "line": 3607 + }, + { + "pum_table_id": "F0183", + "section": "7-8-2. 가선해체", + "pum_form": "requirement", + "line": 3619 + }, + { + "pum_table_id": "F0184", + "section": "7-8-3. 집재 소요인력", + "pum_form": "requirement", + "line": 3631 + }, + { + "pum_table_id": "F0185", + "section": "7-9-1. 수확", + "pum_form": "requirement", + "line": 3656 + }, + { + "pum_table_id": "F0186", + "section": "7-9-2. 숲가꾸기, 소나무재선충병방제", + "pum_form": "requirement", + "line": 3673 + }, + { + "pum_table_id": "F0191", + "section": "7-11. 동력상하차기(우드그래플) 집재-수확", + "pum_form": "requirement", + "line": 3745 + }, + { + "pum_table_id": "F0192", + "section": "7-12. 동력상하차기(우드그래플) 집적", + "pum_form": "requirement", + "line": 3763 + }, + { + "pum_table_id": "F0201", + "section": "8-1-1. 약제주입기", + "pum_form": "requirement", + "line": 3945 + }, + { + "pum_table_id": "F0202", + "section": "8-1-2. 약제주입병", + "pum_form": "requirement", + "line": 3964 + }, + { + "pum_table_id": "F0203", + "section": "8-2-1. 소나무재선충병", + "pum_form": "requirement", + "line": 3982 + }, + { + "pum_table_id": "F0204", + "section": "8-2-1. 소나무재선충병", + "pum_form": "requirement", + "line": 3999 + }, + { + "pum_table_id": "F0206", + "section": "8-2-1. 소나무재선충병", + "pum_form": "requirement", + "line": 4023 + }, + { + "pum_table_id": "F0207", + "section": "8-2-1. 소나무재선충병", + "pum_form": "requirement", + "line": 4041 + }, + { + "pum_table_id": "F0209", + "section": "8-2-2. 솔잎혹파리", + "pum_form": "requirement", + "line": 4080 + }, + { + "pum_table_id": "F0210", + "section": "8-2-2. 솔잎혹파리", + "pum_form": "requirement", + "line": 4108 + }, + { + "pum_table_id": "F0211", + "section": "8-2-2. 솔잎혹파리", + "pum_form": "requirement", + "line": 4136 + }, + { + "pum_table_id": "F0213", + "section": "8-2-3. 솔껍질깍지벌레", + "pum_form": "requirement", + "line": 4182 + }, + { + "pum_table_id": "F0214", + "section": "8-2-3. 솔껍질깍지벌레", + "pum_form": "requirement", + "line": 4210 + }, + { + "pum_table_id": "F0215", + "section": "8-2-3. 솔껍질깍지벌레", + "pum_form": "requirement", + "line": 4238 + }, + { + "pum_table_id": "F0217", + "section": "8-2-4. 솔나방", + "pum_form": "requirement", + "line": 4279 + }, + { + "pum_table_id": "F0219", + "section": "8-2-5. 푸사리움가지마름병", + "pum_form": "requirement", + "line": 4320 + }, + { + "pum_table_id": "F0224", + "section": "8-5. 페르몬 유인트랩", + "pum_form": "requirement", + "line": 4434 + }, + { + "pum_table_id": "F0232", + "section": "8-7. 방제 실행 등록", + "pum_form": "requirement", + "line": 4583 + }, + { + "pum_table_id": "F0235", + "section": "8-9. 잔가지줍기", + "pum_form": "requirement", + "line": 4623 + }, + { + "pum_table_id": "F0236", + "section": "8-10. 그물망 피복", + "pum_form": "requirement", + "line": 4631 + }, + { + "pum_table_id": "F0237", + "section": "8-11. 이동식 임목 파쇄", + "pum_form": "productivity", + "line": 4649 + }, + { + "pum_table_id": "F0238", + "section": "9-2. 노선 굴진 보조원", + "pum_form": "requirement", + "line": 4675 + }, + { + "pum_table_id": "F0241", + "section": "9-4-1. 암파쇄", + "pum_form": "productivity", + "line": 4719 + }, + { + "pum_table_id": "F0244", + "section": "9-5-2. 깎기(90%)", + "pum_form": "productivity", + "line": 4759 + }, + { + "pum_table_id": "F0251", + "section": "9-8-1. T=30㎝ 미만", + "pum_form": "requirement", + "line": 4860 + }, + { + "pum_table_id": "F0256", + "section": "9-11-1. 콘크리트(기계)", + "pum_form": "requirement", + "line": 4946 + }, + { + "pum_table_id": "F0294", + "section": "9-21. 제근", + "pum_form": "requirement", + "line": 5512 + }, + { + "pum_table_id": "F0310", + "section": "10-7-4. 모노레일 운반", + "pum_form": "requirement", + "line": 5770 + }, + { + "pum_table_id": "F0311", + "section": "10-7-4. 모노레일 운반", + "pum_form": "requirement", + "line": 5779 + }, + { + "pum_table_id": "F0312", + "section": "10-7-4. 모노레일 운반", + "pum_form": "requirement", + "line": 5795 + }, + { + "pum_table_id": "F0313", + "section": "10-8-1. 짐내리기", + "pum_form": "requirement", + "line": 5813 + }, + { + "pum_table_id": "F0314", + "section": "10-8-2. 운반대 설치", + "pum_form": "requirement", + "line": 5829 + }, + { + "pum_table_id": "F0315", + "section": "10-8-3. 케이블 크레인 운전", + "pum_form": "requirement", + "line": 5840 + }, + { + "pum_table_id": "F0317", + "section": "10-10-1. 콘크리트 및 골재운반(지상)", + "pum_form": "requirement", + "line": 5884 + }, + { + "pum_table_id": "F0318", + "section": "10-10-2. 그 외 자재의 운반품셈", + "pum_form": "requirement", + "line": 5894 + }, + { + "pum_table_id": "F0337", + "section": "12-5. 문양거푸집(0~7m)", + "pum_form": "requirement", + "line": 6217 + }, + { + "pum_table_id": "F0339", + "section": "12-7-1. 포장절단", + "pum_form": "requirement", + "line": 6254 + }, + { + "pum_table_id": "F0340", + "section": "12-7-2. 줄눈설치", + "pum_form": "requirement", + "line": 6269 + }, + { + "pum_table_id": "F0341", + "section": "12-8. 콘크리트 포장 거푸집", + "pum_form": "requirement", + "line": 6280 + }, + { + "pum_table_id": "F0350", + "section": "12-12. 날개벽", + "pum_form": "requirement", + "line": 6421 + }, + { + "pum_table_id": "F0355", + "section": "12-17-1. 펌프카 타설", + "pum_form": "requirement", + "line": 6499 + }, + { + "pum_table_id": "F0362", + "section": "12-2 표면 마무리를 따른다.", + "pum_form": "requirement", + "line": 6569 + }, + { + "pum_table_id": "F0366", + "section": "12-19. 강관비계", + "pum_form": "requirement", + "line": 6614 + }, + { + "pum_table_id": "F0379", + "section": "12-29. 스페이셔 설치(몰탈 블록)", + "pum_form": "requirement", + "line": 6775 + }, + { + "pum_table_id": "F0395", + "section": "12-38-3. 설치 및 해체", + "pum_form": "requirement", + "line": 6943 + }, + { + "pum_table_id": "F0405", + "section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", + "pum_form": "requirement", + "line": 7087 + }, + { + "pum_table_id": "F0412", + "section": "13-4-4. 찰쌓기(인력)", + "pum_form": "requirement", + "line": 7174 + }, + { + "pum_table_id": "F0413", + "section": "13-4-4. 찰쌓기(인력)", + "pum_form": "requirement", + "line": 7185 + }, + { + "pum_table_id": "F0424", + "section": "13-8. 막돌쌓기", + "pum_form": "requirement", + "line": 7415 + }, + { + "pum_table_id": "F0430", + "section": "13-10-2. 나무 말뚝박기", + "pum_form": "requirement", + "line": 7491 + }, + { + "pum_table_id": "F0437", + "section": "13-12-1. 뭉기기", + "pum_form": "requirement", + "line": 7612 + }, + { + "pum_table_id": "F0438", + "section": "13-12-2. 지오셀(사면보강)", + "pum_form": "requirement", + "line": 7625 + }, + { + "pum_table_id": "F0444", + "section": "13-14. 식생토낭 및 포트", + "pum_form": "requirement", + "line": 7715 + }, + { + "pum_table_id": "F0447", + "section": "13-15-2. 목책 설치", + "pum_form": "requirement", + "line": 7745 + }, + { + "pum_table_id": "F0449", + "section": "13-16-2. 통기성매트", + "pum_form": "requirement", + "line": 7788 + } + ] +} \ No newline at end of file diff --git a/resources/data_work_item_master/form_undetermined_2026-01-01.json b/resources/data_work_item_master/form_undetermined_2026-01-01.json new file mode 100644 index 00000000..9724f909 --- /dev/null +++ b/resources/data_work_item_master/form_undetermined_2026-01-01.json @@ -0,0 +1,1970 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_master_form_undetermined", + "effective_date": "2026-01-01", + "note": "형태를 못 정한 표. 사람이 보고 productivity/requirement/coefficient 로 확정할 것.", + "items": [ + { + "pum_table_id": "F0044", + "section": "2-1-1. 휘발유․오일", + "headers": [ + "기계명(주재료)", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)" + ], + "first_rows": [ + [ + "아키아윈치(휘발유)", + "6.5", + "30" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0045", + "section": "2-1-1. 휘발유․오일", + "headers": [ + "기계명(주재료)", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)" + ], + "first_rows": [ + [ + "2드럼 케이블윈치(휘발유)", + "9.8", + "30" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0047", + "section": "2-1-2. 경유", + "headers": [ + "기계명", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)", + "적용기준" + ], + "first_rows": [ + [ + "트랙터 부착 기계", + "26.0", + "40", + "" + ], + [ + "굴삭기 부착 기계", + "20.8", + "30", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0048", + "section": "2-1-3. 우드그래플 소모품", + "headers": [ + "품 명", + "소요비용(원)" + ], + "first_rows": [ + [ + "트랙 접지력 보강", + "800,000" + ], + [ + "블레이드 및 실린더 교체", + "3,000,000" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0054", + "section": "2-1-5. 약제(농약) 및 친환경비닐랩", + "headers": [ + "주두부직경(㎝)", + "2㎝미만", + "2~6㎝미만", + "6~8㎝미만", + "8㎝이상" + ], + "first_rows": [ + [ + "소금처리량(g)", + "20", + "40", + "60", + "80" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0063", + "section": "2-1-13. 파쇄", + "headers": [ + "소모품", + "소모율", + "가격(원)", + "기타" + ], + "first_rows": [ + [ + "메인파쇄기날", + "0.00125개/hr", + "-", + "" + ], + [ + "분쇄기날", + "0.005개/hr", + "-", + "42개 사용" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0100", + "section": "5-3-1. 나무식재", + "headers": [ + "수종별", + "소 묘", + "중 묘", + "대 묘" + ], + "first_rows": [ + [ + "소나무", + "1-1(노)", + "2-0(용)", + "1-1-2(노), 2-2(용)" + ], + [ + "낙엽송", + "", + "1-1, 2-0(용)", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0101", + "section": "5-3-1. 나무식재", + "headers": [ + "구분", + "침엽수", + "활엽수" + ], + "first_rows": [ + [ + "소묘", + "간장 20cm미만", + "간장 30cm미만" + ], + [ + "중묘", + "간장 20cm이상 40cm미만 또는 간장 40cm이상이고 근원경 8mm미만", + "간장 30cm이상 60cm미만 또는 간장 60cm이상이고 근원경 11mm미만" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0105", + "section": "5-3-4. 교목식재(나무높이)", + "headers": [ + "인력시공시", + "기계시공시" + ], + "first_rows": [ + [ + "인력품의 10%", + "인력품의 20%" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0107", + "section": "5-3-5. 교목식재(흉고직경)", + "headers": [ + "인력시공시", + "기계시공시" + ], + "first_rows": [ + [ + "인력품의 10%", + "인력품의 20%" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0137", + "section": "5-24-2. 뿜어붙이기-기계/마사토", + "headers": [ + "복원목표", + "식 생 구 분", + "종자배합비율(%)" + ], + "first_rows": [ + [ + "초본 위주형", + "관목류", + "20~40" + ], + [ + "초본, 야생화류", + "40~80", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0138", + "section": "5-24-2. 뿜어붙이기-기계/마사토", + "headers": [ + "복원목표", + "식 생 구 분", + "종자배합비율(%)" + ], + "first_rows": [ + [ + "초본 위주형", + "관목류", + "10∼40" + ], + [ + "초본, 야생화류", + "40∼80", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0140", + "section": "5-26-1. 막갈이", + "headers": [ + "토성", + "막갈이깊이(cm)" + ], + "first_rows": [ + [ + "9", + "12", + "15", + "18", + "21", + "" + ], + [ + "사토", + "5", + "7", + "9", + "11", + "13" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0141", + "section": "5-26-2. 흙부수기", + "headers": [ + "토성", + "막갈이깊이(cm)" + ], + "first_rows": [ + [ + "9", + "12", + "15", + "18", + "21", + "" + ], + [ + "사토", + "3", + "4", + "5", + "6", + "7" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0142", + "section": "5-26-3. 돌자갈치우기", + "headers": [ + "토성", + "경토깊이(cm)" + ], + "first_rows": [ + [ + "10% 이내", + "10∼30%", + "30% 이상", + "" + ], + [ + "개답", + "2", + "6", + "17" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0146", + "section": "5-29. 복사이식", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "K(버킷계수)", + "0.9", + "" + ], + [ + "f(체적환산계수)", + "1/1.3", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0169", + "section": "7-1-1. 수확", + "headers": [ + "구 분", + "어려움 (15˚ 미만)", + "중 (30˚ 초과)", + "쉬 움 (15˚~30˚)" + ], + "first_rows": [ + [ + "", + "㎥", + "㎥", + "㎥" + ], + [ + "100m이하", + "1.9", + "3.1", + "4.3" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0187", + "section": "7-9-2. 숲가꾸기, 소나무재선충병방제", + "headers": [ + "노선별 집재재적(㎥/ha)", + "집재거리" + ], + "first_rows": [ + [ + "100m 이하", + "150m 이하", + "200m 이하", + "250m 이하", + "" + ], + [ + "0~20㎥", + "17", + "19", + "23", + "27" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0189", + "section": "7-11. 동력상하차기(우드그래플) 집재-수확", + "headers": [ + "집 재 재 적 (㎥/ha)", + "최대집재거리 : 40m 이하", + "소요 인력" + ], + "first_rows": [ + [ + "원목의 길이", + "", + "", + "", + "", + "", + "" + ], + [ + "1.2m", + "1.8m", + "2.1m", + "2.7m", + "3.6m", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0190", + "section": "7-11. 동력상하차기(우드그래플) 집재-수확", + "headers": [ + "집 재 재 적 (㎥/ha)", + "최대집재거리 : 80m 이하", + "소요 인력" + ], + "first_rows": [ + [ + "원목의 길이", + "", + "", + "", + "", + "", + "" + ], + [ + "1.2m", + "1.8m", + "2.1m", + "2.7m", + "3.6m", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0194", + "section": "7-13. 검척", + "headers": [ + "재장", + "말구직경", + "낙엽송(개수)", + "경급×개수" + ], + "first_rows": [ + [ + "2.1m", + "6", + "2", + "12" + ], + [ + "8", + "11", + "88", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0195", + "section": "7-14. 원목 운반-수확", + "headers": [ + "종 별", + "공 정" + ], + "first_rows": [ + [ + "1회 적 재 량", + "1인 1일공정", + "", + "", + "", + "", + "", + "" + ], + [ + "용 재 (원목)", + "신 재 (활 잡)", + "목 탄", + "운 반", + "상 하 차", + "", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0196", + "section": "7-15-1. 수확", + "headers": [ + "구분", + "주행거리(m이하)", + "인력구분" + ], + "first_rows": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "1200", + "", + "" + ], + [ + "회수", + "10.4", + "9.97", + "9.51", + "9.06", + "8.60", + "8.14", + "7.69", + "7.22", + "6.77", + "6.31", + "5.85", + "건설기계운전기사1명" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0197", + "section": "7-15-2 숲가꾸기, 병해충방제", + "headers": [ + "구 분", + "운반거리(㎞ 이하)", + "인력구분" + ], + "first_rows": [ + [ + "0.5", + "1.0", + "1.5", + "2.0", + "2.5", + "3.0", + "3.5", + "4.0", + "4.5", + "5.0", + "", + "" + ], + [ + "작업회수", + "8.6", + "6.3", + "5.0", + "4.1", + "3.5", + "3.1", + "2.7", + "2.4", + "2.2", + "2.0", + "건설기계운전기사 1인" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0198", + "section": "7-16. 소형 포워더 운재", + "headers": [ + "구분", + "주행거리(m이하)", + "인력구분" + ], + "first_rows": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "1200", + "", + "" + ], + [ + "회수", + "22.97", + "19.20", + "16.50", + "14.46", + "12.87", + "11.60", + "10.55", + "9.68", + "8.94", + "8.31", + "7.28", + "건설기계운전기사 1명" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0199", + "section": "7-17. 소형트럭 운재", + "headers": [ + "구분", + "운반거리(m이하)", + "인력구분" + ], + "first_rows": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "", + "" + ], + [ + "회수", + "23.8", + "20.5", + "18.0", + "16.1", + "14.5", + "13.3", + "12.2", + "11.3", + "10.5", + "9.8", + "건설기계 운전기사 1명" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0200", + "section": "7-18. 지조운반(포워더 활용)", + "headers": [ + "구 분", + "운반거리(m이하)", + "인력구분" + ], + "first_rows": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "", + "" + ], + [ + "회 수", + "22.9", + "17.1", + "13.7", + "11.4", + "9.8", + "8.6", + "7.6", + "6.9", + "6.2", + "5.7", + "건설기계 운전기사 1명" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0205", + "section": "8-2-1. 소나무재선충병", + "headers": [ + "구 분", + "제 형" + ], + "first_rows": [ + [ + "아바멕틴", + "유제 1.8%, 분산성액제 1.8%, 미탁제 1.8%" + ], + [ + "에마멕틴벤조에이트", + "유제 2.15%, 액제 2%, 미탁제 2.15%" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0208", + "section": "8-2-2. 솔잎혹파리", + "headers": [ + "선정약제", + "원액 주입량", + "비고" + ], + "first_rows": [ + [ + "티아메톡삼 분산성액제 15%", + "0.2㎖/㎝", + "" + ], + [ + "디노테퓨란(15)․에마멕틴벤조에이트(4.5) 분산성액제 19.5%", + "0.2㎖/㎝", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0212", + "section": "8-2-3. 솔껍질깍지벌레", + "headers": [ + "선정약제", + "원액 주입량", + "비고" + ], + "first_rows": [ + [ + "디노테퓨란(15)․에마멕틴벤조에이트(4.5) 분산성액제 19.5%", + "0.5㎖/㎝", + "" + ], + [ + "이미다클로프리드 분산성액제 20%", + "0.6㎖/㎝", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0216", + "section": "8-2-4. 솔나방", + "headers": [ + "선정약제", + "원액 주입량", + "비고" + ], + "first_rows": [ + [ + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%", + "0.4㎖/㎝", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0218", + "section": "8-2-5. 푸사리움가지마름병", + "headers": [ + "선정약제", + "원액 주입량", + "비고" + ], + "first_rows": [ + [ + "테부코나졸 유탁제 25%", + "0.5㎖/㎝", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0220", + "section": "8-3. 소각, 매몰, 훈증, 박피", + "headers": [ + "경급별 (㎝)", + "벌목조재 (㎥/인)", + "소운반 (㎥/인)", + "무더기 훈증 (RM/인)", + "그루터기 훈증 (본/인)", + "소각 (㎥/인)", + "매몰 (㎥/인)", + "그루터기 박피 (본/인)", + "원목 박피 (본/인)" + ], + "first_rows": [ + [ + "6", + "1.13", + "5.41", + "14.40", + "169.5", + "7.22", + "1.50", + "100.0", + "19.5" + ], + [ + "8", + "1.18", + "5.50", + "14.73", + "151.5", + "7.39", + "1.50", + "100.0", + "19.5" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0221", + "section": "8-3. 소각, 매몰, 훈증, 박피", + "headers": [ + "수종 가슴 높이 지름", + "소나무", + "낙엽송", + "참나무", + "산오리 나무", + "들오리 나무", + "이태리 포플러", + "아까시 나무" + ], + "first_rows": [ + [ + "4cm", + "", + "32", + "", + "27", + "37", + "35", + "24" + ], + [ + "6", + "", + "28", + "", + "24", + "42", + "29", + "23" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0222", + "section": "8-3. 소각, 매몰, 훈증, 박피", + "headers": [ + "기 준", + "적용대상 수종", + "비율(수간재적의 %)", + "비 고" + ], + "first_rows": [ + [ + "중부지방소나무", + "소나무, 곰솔, 잣나무", + "25", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0227", + "section": "8-6-1. 유인헬기방제", + "headers": [ + "구 분", + "물탱크 용량", + "희석배수", + "유효 살포량/1회", + "원액량" + ], + "first_rows": [ + [ + "소형헬기 (AS350)", + "800", + "8배", + "500", + "62.5" + ], + [ + "16배", + "31.3", + "", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0231", + "section": "8-6-3. 지상방제", + "headers": [ + "물의양 희석 배수", + "10ℓ (0.5말)", + "20ℓ (1말)", + "50ℓ (2.5말)", + "100ℓ (5말)", + "200ℓ (10말)", + "400ℓ (20말)", + "500ℓ (25말)", + "600ℓ (30말)" + ], + "first_rows": [ + [ + "250배", + "40.0", + "80.0", + "200.0", + "400.0", + "800.0", + "1,600.0", + "2,000.0", + "2,400.0" + ], + [ + "500배", + "20.0", + "40.0", + "100.0", + "200.0", + "400.0", + "800.0", + "1,000.0", + "1,200.0" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0248", + "section": "8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질. 3. 체적환산계수적용(2025)」 참조", + "headers": [ + "구 분", + "적 용" + ], + "first_rows": [ + [ + "K", + "0.55" + ], + [ + "f", + "1/1.5(0.67)" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0281", + "section": "9-15-1. 답(畓)구간", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "K(버킷계수)", + "0.9", + "" + ], + [ + "f(토량환산계수)", + "1/1.30", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0282", + "section": "9-15-2. 답(畓)외구간", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "T(표토두께)", + "0.2m", + "" + ], + [ + "L(운반거리)", + "20m", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0284", + "section": "9-16-2. 노체다짐", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "V(다짐속도,km/hr)", + "4", + "" + ], + [ + "W(롤러 유효폭,m)", + "1.9", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0285", + "section": "9-16-3. 살수", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "흡입준비(t1)", + "5분", + "" + ], + [ + "운반(t2)", + "15 km/hr", + "L/V×2×60" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0287", + "section": "9-18. 층따기", + "headers": [ + "적용장비", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "굴착기 (무한궤도, 0.7㎥)", + "K", + "0.9", + "" + ], + [ + "f", + "1/1.3", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0295", + "section": "9-22. 섞기", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "K(버킷계수)", + "0.9", + "" + ], + [ + "f(토량환산계수)", + "1/1.25", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0300", + "section": "10-3-3. 혼합골재", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "운반비", + "골재원 → 현장(덤프 15ton)", + "" + ], + [ + "하차비", + "1회", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0301", + "section": "10-4. 중기운반", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "운반", + "트레일러운반 (20TON)", + "t1=20min", + "" + ], + [ + "t2=운반시간 참조", + "", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0304", + "section": "10-6-3. 기타 임업자재", + "headers": [ + "종 별 거 리", + "목 재", + "볏 짚", + "섶단 · 새", + "나뭇 가지 단", + "자른 떼 (20㎝ ×20㎝)", + "식생낭 (혼토입)", + "편책용 말뚝 (목재, 파이프)", + "볏짚멍석 (종비포함)", + "비 료", + "비 고" + ], + "first_rows": [ + [ + "소 재 (조재목)", + "제재", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "m", + "인/㎥", + "인/㎥", + "인/ 100속", + "인/ 100속", + "인/ 100속", + "인/ 100매", + "인/ 100개", + "인/ 100본", + "인/ 1000㎡", + "인/ 톤", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0308", + "section": "10-7-4. 모노레일 운반", + "headers": [ + "구 분", + "콘크리트", + "토사ㆍ석재", + "블록ㆍ제 자재 등" + ], + "first_rows": [ + [ + "차량구분", + "바스켓 차량", + "보통차량", + "" + ], + [ + "단궤도", + "0.3㎥", + "0.3㎥", + "600kg, 0.3㎥" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0309", + "section": "10-7-4. 모노레일 운반", + "headers": [ + "구 분", + "콘크리트", + "토사ㆍ석재 등", + "블록ㆍ제 자재 등" + ], + "first_rows": [ + [ + "시간(분)", + "4.0", + "4.0", + "6.0" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0316", + "section": "10-9-1. 자재의 1회당 표준 운반량", + "headers": [ + "규격 구분", + "1t미만", + "1t이상 2t미만", + "2t이상 3t미만", + "3t이상 4t미만", + "4t이상 5t미만", + "비고" + ], + "first_rows": [ + [ + "콘크리트 블 록", + "350kg(10.9개)", + "530kg(16.8개)", + "720kg(22.8개)", + "910kg(28.7개)", + "1100kg(34.7개)", + "" + ], + [ + "목제형틀", + "170kg(14㎡)", + "220kg(18㎡)", + "270kg(22㎡)", + "320kg(26㎡)", + "370kg(30㎡)", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0319", + "section": "10-11. 불도저 운반", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "L", + "20m", + "", + "" + ], + [ + "E", + "토사", + "0.55", + "자연상태,불량" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0320", + "section": "10-12-1. 토사", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "K", + "0.9(0.55)", + "동 일", + "" + ], + [ + "E0", + "토사", + "0.60(불량)", + "임 도" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0321", + "section": "10-12-1. 토사", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "적재(t1)", + "굴착기", + "적재방법에 따라 산출", + "" + ], + [ + "t2", + "V1(적재)", + "5 km/hr", + "왕복시간" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0322", + "section": "10-13-1. 드론운반", + "headers": [ + "수평거리", + "100m", + "200m", + "300m", + "400m", + "500m", + "600m" + ], + "first_rows": [ + [ + "인원(명)", + "0.29", + "0.37", + "0.46", + "0.54", + "0.62", + "0.70" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0323", + "section": "10-13-2. 인력운반", + "headers": [ + "수평거리", + "100m", + "200m", + "300m", + "400m", + "500m", + "600m" + ], + "first_rows": [ + [ + "인원(명)", + "0.67", + "0.73", + "0.79", + "0.85", + "0.92", + "0.99" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0324", + "section": "10-13-2. 인력운반", + "headers": [ + "항 목", + "내 용", + "적용치" + ], + "first_rows": [ + [ + "① 드론준비시간(분)", + "ㆍ이륙지점에 도착한 후 묘목운반 개시까지 준비시간 (점검, 보정, 테스트 비행을 포함)", + "60" + ], + [ + "② 왕복비행시간(분)", + "ㆍ운반거리에 대한 드론의 왕복 시간 (적재, 하적, 배터리 교환을 포함)", + "0.0098×수평거리+1.3264" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0328", + "section": "11-4. 쇄석·혼합석 부설", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "부설장비", + "유압식백호우 (무한궤도 0.7㎥)", + "q", + "0.7", + "" + ], + [ + "k", + "0.55", + "", + "", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0333", + "section": "12-1-3. 인력비빔타설", + "headers": [ + "온도 품종", + "00C때", + "-50C때", + "-100C때", + "-200C때" + ], + "first_rows": [ + [ + "25", + "(A)", + "357", + "893", + "931" + ], + [ + "(B)", + "346", + "828", + "1,011", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0346", + "section": "12-11-1. VR관(소켓식)", + "headers": [ + "구 분", + "규격", + "단위", + "관 경 별 적 용", + "비 고" + ], + "first_rows": [ + [ + "∅800mm", + "∅1000mm", + "∅1200mm", + "", + "", + "", + "" + ], + [ + "VR관", + "", + "m", + "1.0", + "1.0", + "1.0", + "별도계산" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0349", + "section": "12-11-3. 파형강관", + "headers": [ + "구 분", + "규격", + "단위", + "관 경 별 적 용", + "비 고" + ], + "first_rows": [ + [ + "∅800mm", + "∅1000mm", + "∅1200mm", + "", + "", + "", + "" + ], + [ + "파형강관", + "", + "m", + "1.0", + "1.0", + "1.0", + "별산" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0356", + "section": "12-2 표면 마무리를 따른다.", + "headers": [ + "슬 럼 프", + "기준 시공량" + ], + "first_rows": [ + [ + "무근콘크리트", + "철근콘크리트", + "" + ], + [ + "8 ~ 12 cm", + "130", + "125" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0358", + "section": "12-2 표면 마무리를 따른다.", + "headers": [ + "구 분", + "적용 기준" + ], + "first_rows": [ + [ + "Type-Ⅰ", + "매트기초 등 펌프차 작업에 제약이 없는 시설물" + ], + [ + "Type-Ⅱ", + "벽, 기둥, 보, 슬래브. 교대, 교각 등 펌프차 작업에 큰 지장이 없어 일반적인 시공이 가능한 시설물" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0360", + "section": "12-2 표면 마무리를 따른다.", + "headers": [ + "구 분", + "적용 기준" + ], + "first_rows": [ + [ + "Type-Ⅰ", + "대기 공간이 충분히 넓어 믹서트럭 2대가 병렬로 타설 준비가 가능하며 지속적인 타설을 수행하는 경우" + ], + [ + "Type-Ⅱ", + "믹서트럭이 1대씩 직렬로 대기하며 순차적으로 타설 준비하여 타설하는 일반적인 경우" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0388", + "section": "12-34-4. 채움재(T=20m/m)", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "재료비", + "JOINT FILLER", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0390", + "section": "12-36. P.C BOX 설치", + "headers": [ + "구 분", + "적 용", + "비 고" + ], + "first_rows": [ + [ + "제작비", + "견적처리", + "" + ], + [ + "운송비", + "견적처리", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0392", + "section": "12-38-1. 사용횟수", + "headers": [ + "구 분", + "사용 조작 회수" + ], + "first_rows": [ + [ + "패 널 류 보, 드롭헤드, 강관파이프, 훅  클래프, 웨지핀", + "12회 사용 잔존율 25% 25회 사용 잔존율 10%" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0394", + "section": "12-38-2. 사용수량", + "headers": [ + "구 분", + "간 단", + "보 통", + "복 잡" + ], + "first_rows": [ + [ + "요 율", + "24%", + "52%", + "79%" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0396", + "section": "12-38-3. 설치 및 해체", + "headers": [ + "구 분", + "유 형" + ], + "first_rows": [ + [ + "복 잡", + "토목 : 교대, 날개벽 등 복잡하고 보강이 많은 구조 건축 : 외부 벽체, 보/기둥" + ], + [ + "보 통", + "측구, 수로, 옹벽, 일반적인 벽체, 박스 등" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0400", + "section": "13-2-3. 큰돌 채집", + "headers": [ + "명칭", + "단위", + "규격(직경)" + ], + "first_rows": [ + [ + "40㎝이상∼60㎝미만", + "60㎝이상∼80㎝미만", + "80㎝이상∼100㎝이하", + "", + "" + ], + [ + "뒷길이", + "㎝", + "60 ~ 75", + "75 ~ 95", + "95 ~ 120" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0406", + "section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", + "headers": [ + "종별 뒷길이 단위", + "견치돌", + "깬돌 및 깬잡석", + "야면석" + ], + "first_rows": [ + [ + "25㎝(17×17)", + "개 ㎏", + "32 192", + "33 132", + "- -" + ], + [ + "30㎝(20×20)", + "개 ㎏", + "23 368", + "24 264", + "28 420" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0408", + "section": "13-4-3. 고임돌 소요량", + "headers": [ + "뒷길이 종별", + "25㎝", + "30㎝", + "35㎝", + "45㎝", + "55㎝", + "60㎝", + "75㎝" + ], + "first_rows": [ + [ + "야면석(㎥) 깬잡석(㎥) 깬 돌(㎥) 견치돌(㎥)", + "0.06 0.09 - -", + "0.07 0.11 0.10 -", + "0.09 0.13 0.12 0.12", + "0.11 0.16 0.15 0.15", + "0.14 0.19 0.18 0.18", + "0.15 0.21 0.20 0.20", + "- 0.26 0.25 0.25" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0410", + "section": "13-4-4. 찰쌓기(인력)", + "headers": [ + "뒷길이 종별", + "25㎝", + "30㎝", + "35㎝", + "45㎝", + "55㎝", + "60㎝", + "75㎝", + "비고" + ], + "first_rows": [ + [ + "야면석(㎥) 호박돌(㎥)", + "0.08 0.08", + "0.10 0.10", + "0.12 0.12", + "0.15 0.15", + "0.18 0.18", + "0.20 0.20", + "0.25 0.25", + "뒷길이 33.3% 〃" + ], + [ + "깬잡석(㎥) 깬 돌(㎥) 견치돌(㎥)", + "0.11 0.11 0.11", + "0.14 0.14 0.14", + "0.16 0.16 0.16", + "0.20 0.20 0.20", + "0.25 0.25 0.25", + "0.27 0.27 0.27", + "0.34 0.34 0.34", + "뒷길이 45% 〃 〃" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0411", + "section": "13-4-4. 찰쌓기(인력)", + "headers": [ + "직 고(直高)", + "∼1.5m", + "∼3.0m", + "∼5.0m", + "∼7.0m" + ], + "first_rows": [ + [ + "상부의 두께(㎝) 하부의 두께(㎝)", + "20~40 30~60", + "20~40 45~75", + "20~40 60~100", + "20~40 80~140" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0429", + "section": "13-10-2. 나무 말뚝박기", + "headers": [ + "관입률 =", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "비고" + ], + "first_rows": [ + [ + "계수", + "0.2", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0439", + "section": "13-12-2. 지오셀(사면보강)", + "headers": [ + "구 분 공정별", + "1:2 표준", + "1:2 ~ 1:1.5", + "1:1.5 ~ 1:1", + "1:1 이상" + ], + "first_rows": [ + [ + "4m 표준", + "1", + "10", + "15", + "30" + ], + [ + "4 ~ 10m", + "10", + "20", + "25", + "40" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0446", + "section": "13-15-2. 목책 설치", + "headers": [ + "작업조건", + "벌도목 직경", + "가지량", + "경사도" + ], + "first_rows": [ + [ + "어려운 조건", + "26 ~ 30cm", + "많음", + "15°이상" + ], + [ + "쉬운 조건", + "20 ~ 24cm", + "중간 이하", + "15°미만" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + }, + { + "pum_table_id": "F0453", + "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", + "headers": [ + "사업종", + "구분", + "적용 가능 공종" + ], + "first_rows": [ + [ + "공종", + "", + "", + "", + "", + "" + ], + [ + "조림", + "시행 지침", + "3-5 예정지 정리작업", + "5-3-1 나무식재", + "5-30 표시봉설치", + "5-31 지주목설치" + ] + ], + "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" + } + ] +} \ No newline at end of file diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json new file mode 100644 index 00000000..44658daf --- /dev/null +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -0,0 +1,43383 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_master_forest", + "effective_date": "2026-01-01", + "generated_at": "2026-09-08T08:33:10+09:00", + "dataset_version": { + "dataset_id": "pum_forest", + "effective_date": "2026-01-01", + "sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd", + "file": "pum_forest_2026.json" + }, + "policy": { + "axis": "work_item_only", + "resource_axis_owner": "B09", + "no_invented_values": true, + "raw_row_preserved": true + }, + "stats": { + "toc_nodes": 477, + "tables_total": 475, + "tables_attached": 456, + "tables_orphan": 19, + "form_undetermined": 77, + "basis_found": 185, + "basis_missing": 135, + "basis_grouped": 36 + }, + "orphan_tables": [ + { + "pum_table_id": "F0248", + "section": "8-2-13 대형브레이커(2025)」, 「건설공사 표준품셈 공통부문, 1-2-3 토질. 3. 체적환산계수적용(2025)」 참조" + }, + { + "pum_table_id": "F0257", + "section": "1-6-6 포장줄눈 절단(2025)」 참조한다." + }, + { + "pum_table_id": "F0364", + "section": "12-17-3. 무근진동기 제외" + }, + { + "pum_table_id": "F0375", + "section": "12-27-1. 지수판 설치" + }, + { + "pum_table_id": "F0457", + "section": "2-3. 풀베기, (3) 줄베기(조림목 본수 2,700본/ha, 조림2년차)" + }, + { + "pum_table_id": "F0458", + "section": "2-4. 풀베기, (4) 맹아제거+둘레베기(제거대상 맹아 1,000본/ha. 조림목 본수 2,700본/ha, 조림1년차)" + }, + { + "pum_table_id": "F0459", + "section": "2-5. 덩굴제거, (1) 지상부 덩굴걷기(큰나무 피해지. 덩굴 피복도 20~40%미만)" + }, + { + "pum_table_id": "F0460", + "section": "2-6. 덩굴제거, (2) 지상부 약제살포(큰나무 피해지. 덩굴 피복도 60~80%미만)" + }, + { + "pum_table_id": "F0461", + "section": "2-7. 덩굴제거, (3) 뿌리굴취(풀베기단계 조림지 1㎝미만 500본, 1~4㎝ 400본, 4㎝초과 100본)" + }, + { + "pum_table_id": "F0462", + "section": "2-8. 덩굴제거, (4) 뿌리굴취(풀베기단계 조림지 1㎝미만 500본, 1~4㎝ 400본," + }, + { + "pum_table_id": "F0463", + "section": "2-9. 어린나무가꾸기, (1) 치수림단계 (20m 간격 소작업로 설치, 제거대상 피복도 ‘소’, 가지치기 미실행)" + }, + { + "pum_table_id": "F0464", + "section": "2-10. 어린나무가꾸기, (2) 유령림단계 (제거대상 피복도 ‘밀’, 가지치기 잣나무 0~2m. 500본/ha)" + }, + { + "pum_table_id": "F0465", + "section": "2-11. 솎아베기, (1) 산물을 임내에 버리는 경우" + }, + { + "pum_table_id": "F0466", + "section": "2-12. 솎아베기, (2) 산물을 전간재로 생산하는 경우" + }, + { + "pum_table_id": "F0467", + "section": "2-13. 위험목 베기" + }, + { + "pum_table_id": "F0468", + "section": "2-14. 산물수집, (1) 인력집재(단목 집재 + 집적)" + }, + { + "pum_table_id": "F0469", + "section": "2-15. 산물수집, (2) 지면끌기집재(공정별 독립작업)" + }, + { + "pum_table_id": "F0470", + "section": "2-16. 산물수집, (3) 지면끌기집재 (동시작업)" + }, + { + "pum_table_id": "F0471", + "section": "2-17. 산물임내정리" + } + ], + "work_items": [ + { + "work_item_code": "FP-01", + "number": "1", + "name": "적용기준", + "level": 1, + "parent_code": null, + "sort_order": 256, + "tables": [] + }, + { + "work_item_code": "FP-01-01", + "number": "1-1", + "name": "일반사항", + "level": 2, + "parent_code": "FP-01", + "sort_order": 512, + "tables": [] + }, + { + "work_item_code": "FP-01-01-01", + "number": "1-1-1", + "name": "목적", + "level": 3, + "parent_code": "FP-01-01", + "sort_order": 768, + "tables": [] + }, + { + "work_item_code": "FP-01-01-02", + "number": "1-1-2", + "name": "적용범위", + "level": 3, + "parent_code": "FP-01-01", + "sort_order": 1024, + "tables": [ + { + "pum_table_id": "F0152", + "section": "1-1-2 분뜨기묘) 15,000본을 80㎞ 운반할 경우의 대운반비 산출", + "source_line": 3049, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "2.5톤 차량", + "소 묘", + "중 묘", + "대 묘", + "소 2-2 (용기묘)", + "※ 묘목 1,000본당 무게 - 소나무 노지묘 1-1 (45㎏) - 낙엽송, 편백 용기묘 2-0 (200㎏) - 상수리 용기묘 1-0 (200㎏)" + ], + "condition_note": [ + "규 격", + "수종 및 묘령(형태)", + "적재량", + "비 고" + ], + "raw_row": [ + [ + "2.5톤 차량", + "5.0톤 차량", + "", + "", + "" + ], + [ + "소 묘", + "소나무 1-1(노지묘)", + "110곤포", + "200곤포", + "500본/곤포" + ], + [ + "중 묘", + "편백 2-0(용기묘)", + "150박스", + "300박스", + "100본/박스" + ], + [ + "대 묘", + "해송 1-1-2(노지묘)", + "1,800본", + "3,000본", + "분뜨기묘 1본" + ], + [ + "소 2-2 (용기묘)", + "300곤포", + "800곤포", + "10본/곤포", + "" + ], + [ + "※ 묘목 1,000본당 무게 - 소나무 노지묘 1-1 (45㎏) - 낙엽송, 편백 용기묘 2-0 (200㎏) - 상수리 용기묘 1-0 (200㎏)", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-01-03", + "number": "1-1-3", + "name": "적용방법", + "level": 3, + "parent_code": "FP-01-01", + "sort_order": 1280, + "tables": [] + }, + { + "work_item_code": "FP-01-02", + "number": "1-2", + "name": "설계 및 수량", + "level": 2, + "parent_code": "FP-01", + "sort_order": 1536, + "tables": [] + }, + { + "work_item_code": "FP-01-02-01", + "number": "1-2-1", + "name": "수량의 계산", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 1792, + "tables": [] + }, + { + "work_item_code": "FP-01-02-02", + "number": "1-2-2", + "name": "단위 표준", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 2048, + "tables": [ + { + "pum_table_id": "F0002", + "section": "1-2-2. 단위 표준", + "source_line": 577, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "토 적(높이, 너비)", + "토 적(단면적)", + "토 적(체적)", + "사 석(捨石)", + "다 듬 돌(切石, 板石)", + "목 재(판재)", + "구 리 판, 함 석 류", + "도 장(塗裝)", + "관 류(管類)" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "단 위", + "공사연장", + "공사폭원", + "직공인부", + "공사 및 사업면적", + "용지면적", + "토 적(높이, 너비)", + "토 적(단면적)", + "토 적(체적)", + "토적(체적합계)", + "떼", + "모래,자갈", + "조약돌", + "견치돌, 깬돌", + "야 면 석(野面石)", + "cm", + "돌쌓기 및 돌붙임", + "사 석(捨石)", + "다 듬 돌(切石, 板石)", + "벽돌", + "블록", + "시멘트", + "모르타르", + "콘크리트" + ], + "condition_note": [ + "종 목", + "규 격", + "단위수량", + "비 고" + ], + "raw_row": [ + [ + "단 위", + "소수자리", + "단 위", + "소수자리", + "", + "" + ], + [ + "공사연장", + "m", + "2", + "m", + "-", + "" + ], + [ + "공사폭원", + "-", + "-", + "m", + "1", + "" + ], + [ + "직공인부", + "-", + "-", + "인", + "2", + "" + ], + [ + "공사 및 사업면적", + "-", + "-", + "㎡", + "-", + "" + ], + [ + "용지면적", + "-", + "-", + "㎡", + "-", + "" + ], + [ + "토 적(높이, 너비)", + "-", + "-", + "m", + "2", + "" + ], + [ + "토 적(단면적)", + "-", + "-", + "㎡", + "1", + "" + ], + [ + "토 적(체적)", + "-", + "-", + "㎥", + "2", + "" + ], + [ + "토적(체적합계)", + "-", + "-", + "㎥", + "-", + "" + ], + [ + "떼", + "cm", + "-", + "㎡", + "1", + "" + ], + [ + "모래,자갈", + "cm", + "-", + "㎥", + "2", + "" + ], + [ + "조약돌", + "cm", + "-", + "㎥", + "2", + "" + ], + [ + "견치돌, 깬돌", + "cm cm", + "- -", + "㎡ 개", + "1", + "" + ], + [ + "야 면 석(野面石)", + "cm", + "-", + "개", + "-", + "" + ], + [ + "cm", + "-", + "㎥", + "1", + "", + "" + ], + [ + "cm", + "-", + "㎡", + "-", + "", + "" + ], + [ + "돌쌓기 및 돌붙임", + "cm cm", + "- -", + "㎥ ㎡", + "1 1", + "" + ], + [ + "사 석(捨石)", + "cm", + "-", + "㎥", + "1", + "" + ], + [ + "다 듬 돌(切石, 板石)", + "cm", + "-", + "개", + "2", + "" + ], + [ + "벽돌", + "mm", + "-", + "개", + "-", + "" + ], + [ + "블록", + "mm", + "-", + "개", + "-", + "" + ], + [ + "시멘트", + "-", + "-", + "kg", + "-", + "" + ], + [ + "모르타르", + "-", + "-", + "kg", + "2", + "" + ], + [ + "콘크리트", + "-", + "-", + "㎥", + "2", + "" + ], + [ + "석분", + "-", + "-", + "kg", + "-", + "" + ], + [ + "석회", + "-", + "-", + "kg", + "-", + "" + ], + [ + "화산회", + "-", + "-", + "kg", + "-", + "" + ], + [ + "아스팔트", + "-", + "-", + "kg", + "-", + "" + ], + [ + "목 재(판재)", + "길이m", + "1", + "㎡", + "2", + "" + ], + [ + "폭,두께cm", + "1", + "㎥", + "3", + "", + "" + ], + [ + "합판", + "mm", + "-", + "장", + "1", + "" + ], + [ + "말뚝", + "길이m", + "1", + "개", + "-", + "" + ], + [ + "지름mm", + "-", + "", + "", + "", + "" + ], + [ + "철강재", + "mm", + "-", + "kg", + "3", + "" + ], + [ + "용접봉", + "mm", + "-", + "kg", + "1", + "" + ], + [ + "구 리 판, 함 석 류", + "-", + "-", + "㎡", + "2", + "" + ], + [ + "철근", + "mm", + "-", + "kg", + "-", + "" + ], + [ + "볼트・너트", + "mm", + "-", + "개", + "-", + "" + ], + [ + "꺽쇠", + "mm", + "-", + "개", + "-", + "" + ], + [ + "철선류", + "mm", + "1", + "kg", + "2", + "" + ], + [ + "PC강선", + "-", + "-", + "kg", + "2", + "" + ], + [ + "돌망태", + "길이m", + "1", + "m", + "1", + "" + ], + [ + "지름m", + "-", + "개", + "-", + "", + "" + ], + [ + "높이m", + "-", + "", + "", + "", + "" + ], + [ + "로프류", + "mm", + "1", + "m", + "1", + "" + ], + [ + "못", + "길이cm", + "1", + "kg", + "2", + "" + ], + [ + "석유, 휘발유, 모빌유", + "-", + "-", + "ℓ", + "2", + "" + ], + [ + "구리스", + "-", + "-", + "kg", + "2", + "" + ], + [ + "넝마", + "-", + "-", + "kg", + "1", + "" + ], + [ + "화약류", + "-", + "-", + "kg", + "3", + "" + ], + [ + "뇌관", + "-", + "-", + "개", + "-", + "" + ], + [ + "도화선", + "-", + "-", + "m", + "1", + "" + ], + [ + "석탄, 목탄, 코크스", + "-", + "-", + "kg", + "1", + "" + ], + [ + "산소", + "-", + "-", + "ℓ", + "-", + "" + ], + [ + "카바이트", + "-", + "-", + "kg", + "1", + "" + ], + [ + "도 료(塗料)", + "-", + "-", + "ℓ또는kg", + "2", + "" + ], + [ + "도 장(塗裝)", + "-", + "-", + "㎡", + "1", + "" + ], + [ + "관 류(管類)", + "길이m", + "2", + "개", + "-", + "" + ], + [ + "지름mm", + "-", + "", + "", + "", + "" + ], + [ + "두께mm", + "-", + "", + "", + "", + "" + ], + [ + "수로연장", + "-", + "-", + "m", + "1", + "" + ], + [ + "옹벽", + "-", + "-", + "㎡", + "1", + "" + ], + [ + "승강장옹벽 및 울타리", + "-", + "-", + "m", + "1", + "" + ], + [ + "궤도부설", + "-", + "-", + "km", + "3", + "" + ], + [ + "시험하중", + "-", + "-", + "ton", + "-", + "" + ], + [ + "보오링", + "-", + "-", + "m", + "1", + "" + ], + [ + "방수면적", + "-", + "-", + "㎡", + "1", + "" + ], + [ + "건물(면적)", + "-", + "-", + "㎡", + "2", + "" + ], + [ + "건물(지붕, 벽붙이기)", + "-", + "-", + "㎡", + "1", + "" + ], + [ + "우물", + "깊이", + "-", + "m", + "1", + "" + ], + [ + "마대", + "-", + "-", + "매", + "-", + "" + ] + ] + }, + { + "pum_table_id": "F0003", + "section": "1-2-2. 단위 표준", + "source_line": 657, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "설계서의 총액", + "설계서의 소계", + "설계서의 금액란", + "일위대가표의 계금", + "일위대가표의 금액란" + ], + "condition_note": [ + "종 목", + "단 위", + "지위", + "비 고" + ], + "raw_row": [ + [ + "설계서의 총액", + "원", + "1,000", + "미만버림" + ], + [ + "설계서의 소계", + "원", + "1", + "미만버림" + ], + [ + "설계서의 금액란", + "원", + "1", + "미만버림" + ], + [ + "일위대가표의 계금", + "원", + "1", + "미만버림" + ], + [ + "일위대가표의 금액란", + "원", + "0.1", + "미만버림" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-02-03", + "number": "1-2-3", + "name": "토질", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 2304, + "tables": [ + { + "pum_table_id": "F0004", + "section": "1-2-3. 토질", + "source_line": 709, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "경 암 ( 硬 岩 )", + "보 통 암 ( 普 通 硬 岩 )", + "연 암 ( 軟 岩 )", + "풍 화 암 ( 風 化 岩 )", + "폐 콘 크 리 트", + "호 박 돌 ( 玉 石 )", + "역 (礫 )", + "역 질 토 ( 礫 質 土 )", + "고결(固結)된 역질토(礫質土)", + "모 래 ( 砂 )", + "암괴(岩塊)나 호박돌이 섞인 모래", + "점 질 토", + "역(礫)이 섞인 점질토(粘質土)", + "암괴(岩塊)나 호박돌이 섞인 점토", + "점 토 ( 粘 土 )", + "역 이 섞 인 점 질 토", + "암 괴 ( 岩 塊 ) 나 호 박 돌 이 섞 인 점 토" + ], + "condition_note": [ + "종 별", + "L", + "C" + ], + "raw_row": [ + [ + "경 암 ( 硬 岩 )", + "1.70∼2.00", + "1.30∼1.50" + ], + [ + "보 통 암 ( 普 通 硬 岩 )", + "1.55∼1.70", + "1.20∼1.40" + ], + [ + "연 암 ( 軟 岩 )", + "1.30∼1.50", + "1.00∼1.30" + ], + [ + "풍 화 암 ( 風 化 岩 )", + "1.30∼1.35", + "1.00∼1.15" + ], + [ + "폐 콘 크 리 트", + "1.40∼1.60", + "별도 설계" + ], + [ + "호 박 돌 ( 玉 石 )", + "1.10∼1.15", + "0.95∼1.05" + ], + [ + "역 (礫 )", + "1.10∼1.20", + "1.05∼1.10" + ], + [ + "역 질 토 ( 礫 質 土 )", + "1.15∼1.20", + "0.90∼1.00" + ], + [ + "고결(固結)된 역질토(礫質土)", + "1.25∼1.45", + "1.10∼1.30" + ], + [ + "모 래 ( 砂 )", + "1.20∼1.30", + "0.85~0.90" + ], + [ + "암괴(岩塊)나 호박돌이 섞인 모래", + "1.40∼1.45", + "0.90~0.95" + ], + [ + "점 질 토", + "1.25~1.35", + "0.85~0.95" + ], + [ + "역(礫)이 섞인 점질토(粘質土)", + "1.35~1.40", + "0.90~1.00" + ], + [ + "암괴(岩塊)나 호박돌이 섞인 점토", + "1.40~1.45", + "0.90~0.95" + ], + [ + "점 토 ( 粘 土 )", + "1.20~1.45", + "0.85~0.95" + ], + [ + "역 이 섞 인 점 질 토", + "1.30~1.40", + "0.90~0.95" + ], + [ + "암 괴 ( 岩 塊 ) 나 호 박 돌 이 섞 인 점 토", + "1.40~1.45", + "0.90~0.95" + ] + ] + }, + { + "pum_table_id": "F0005", + "section": "1-2-3. 토질", + "source_line": 732, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자연상태의 체적", + "흐트러진 상태의 체적" + ], + "condition_note": [ + "구하는 Q 기준이 되는 q", + "자연상태의 체적", + "흐트러진 상태의 체적", + "하져진 후의 체적" + ], + "raw_row": [ + [ + "자연상태의 체적", + "1", + "L", + "C" + ], + [ + "흐트러진 상태의 체적", + "1/L", + "1", + "C/L" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-02-04", + "number": "1-2-4", + "name": "재료 및 자재의 단가", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 2560, + "tables": [ + { + "pum_table_id": "F0006", + "section": "1-2-4. 재료 및 자재의 단가", + "source_line": 759, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "사용고재(시멘트공대 및 공드람 제외)", + "강재스크랩(Scrap)", + "기타발생재" + ], + "condition_note": [ + "품 명", + "공제율" + ], + "raw_row": [ + [ + "사용고재(시멘트공대 및 공드람 제외)", + "90%" + ], + [ + "강재스크랩(Scrap)", + "70%" + ], + [ + "기타발생재", + "발생량" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-02-05", + "number": "1-2-5", + "name": "노임", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 2816, + "tables": [] + }, + { + "work_item_code": "FP-01-02-06", + "number": "1-2-6", + "name": "공구손료 및 잡재료", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 3072, + "tables": [] + }, + { + "work_item_code": "FP-01-02-07", + "number": "1-2-7", + "name": "운반", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 3328, + "tables": [ + { + "pum_table_id": "F0007", + "section": "1-2-7. 운반", + "source_line": 790, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "6톤 차량", + "목재(원목)", + "목재(제재목)", + "경유ㆍ휘발유", + "아스팔트", + "새끼", + "벽돌", + "기와", + "보도블록", + "견치돌", + "블록", + "두께 15cm", + "두께 20cm", + "타일", + "크링커타일", + "합판 유리", + "페인트", + "아스타일", + "흄관", + "450 "", + "600 "", + "800 "", + "900 "", + "1,000 "" + ], + "condition_note": [ + "종 별", + "규격", + "단위", + "적재량", + "비고" + ], + "raw_row": [ + [ + "6톤 차량", + "8톤 차량", + "11톤 차량", + "20톤 트레일러", + "", + "", + "", + "" + ], + [ + "목재(원목)", + "길이가 긴 것은 낱개", + "㎥", + "7.7", + "10", + "13", + "-", + "" + ], + [ + "목재(제재목)", + "길이가 긴 것은 낱개", + "㎥", + "9.0", + "12", + "16", + "-", + "" + ], + [ + "경유ㆍ휘발유", + "200ℓ", + "드럼", + "30", + "40", + "55", + "-", + "" + ], + [ + "아스팔트", + "200ℓ", + "드럼", + "24", + "35", + "50", + "-", + "" + ], + [ + "새끼", + "12㎜, 9.4kg", + "다발", + "480", + "640", + "-", + "-", + "" + ], + [ + "벽돌", + "19cm×9cm×5.7cm(표준형)", + "개", + "2,930", + "3,900", + "5,300", + "-", + "" + ], + [ + "기와", + "34cm×30cm×1.5cm", + "매", + "1,860", + "2,480", + "3,400", + "-", + "" + ], + [ + "보도블록", + "30cm×45cm×6cm", + "개", + "490", + "650", + "890", + "-", + "" + ], + [ + "견치돌", + "뒷길이 45cm", + "개", + "100", + "135", + "180", + "-", + "" + ], + [ + "블록", + "두께 10cm", + "개", + "650", + "860", + "1,180", + "-", + "" + ], + [ + "두께 15cm", + "개", + "450", + "600", + "820", + "-", + "", + "" + ], + [ + "두께 20cm", + "개", + "350", + "460", + "630", + "-", + "", + "" + ], + [ + "타일", + "두께 6㎜", + "㎡", + "500", + "660", + "-", + "-", + "모자이크 포함" + ], + [ + "타일", + "두께 (8㎜)", + "㎡", + "(350)", + "(460)", + "-", + "-", + "" + ], + [ + "크링커타일", + "두께 24㎜", + "㎡", + "150", + "200", + "-", + "-", + "" + ], + [ + "합판 유리", + "12×900×1,800㎜ 두께 3㎜", + "매 ㎡", + "450 700", + "600 930", + "820 -", + "- -", + "" + ], + [ + "페인트", + "4ℓ(18ℓ)/통", + "통", + "1,300", + "1,720", + "2,365", + "", + "" + ], + [ + "", + "", + "", + "(300)", + "(400)", + "(550)", + "", + "" + ], + [ + "아스타일", + "3㎜×30cm×30cm", + "매", + "9,600", + "12,800", + "17,650", + "-", + "" + ], + [ + "흄관", + "ø300㎜ L=2.5m", + "본", + "27", + "36", + "52", + "-", + "" + ], + [ + "450 "", + """, + "15", + "20", + "27", + "-", + "", + "" + ], + [ + "600 "", + """, + "8", + "12", + "15", + "-", + "", + "" + ], + [ + "800 "", + """, + "4", + "6", + "9", + "-", + "", + "" + ], + [ + "900 "", + """, + "4", + "5", + "7", + "-", + "", + "" + ], + [ + "1,000 "", + """, + "3", + "4", + "5", + "10", + "", + "" + ], + [ + "1,200 "", + """, + "2", + "3", + "4", + "7", + "", + "" + ], + [ + "1,500 "", + """, + "1", + "2", + "2", + "5", + "", + "" + ], + [ + "콘크리트관", + "ø250㎜ L=1m", + "본", + "60", + "80", + "110", + "-", + "" + ], + [ + "300 "", + """, + "52", + "70", + "96", + "-", + "", + "" + ], + [ + "350 "", + """, + "42", + "60", + "82", + "-", + "", + "" + ], + [ + "450 "", + """, + "25", + "30", + "41", + "-", + "", + "" + ], + [ + "600 "", + """, + "16", + "20", + "27", + "-", + "", + "" + ], + [ + "900 "", + """, + "9", + "12", + "16", + "-", + "", + "" + ], + [ + "1,000~1,500 "", + """, + "3~6", + "4~8", + "5~10", + "12", + "", + "" + ], + [ + "주철관", + "ø80㎜~150㎜L=6.0m", + "본", + "42~111", + "46~123", + "-", + "-", + "" + ], + [ + "200~450 "", + """, + "9~30", + "10~34", + "-", + "-", + "", + "" + ], + [ + "200~450 "", + """, + "6", + "6~9", + "-", + "-", + "", + "" + ], + [ + "200~450 "", + """, + "3", + "3~5", + "-", + "-", + "", + "" + ], + [ + "1,000 "", + """, + "2", + "2", + "-", + "-", + "", + "" + ], + [ + "도복장강관", + "ø300㎜~450㎜ L=6.0m", + "본", + "10~18", + "14~22", + "-", + "-", + "" + ], + [ + "500~700"", + """, + "3~9", + "6~10", + "-", + "-", + "", + "" + ], + [ + "800~1,000"", + """, + "1~3", + "3", + "-", + "-", + "", + "" + ], + [ + "1,200~2,100"", + """, + "1", + "1", + "-", + "-", + "", + "" + ], + [ + "2,200~2,300"", + """, + "-", + "1", + "-", + "-", + "", + "" + ], + [ + "P・C 파일", + "ø300㎜~450㎜L=9.0m", + "본", + "-", + "-", + "6~10", + "11~18", + "" + ], + [ + "450~500"", + """, + "-", + "-", + "4~5", + "8~9", + "", + "" + ], + [ + "시멘트", + "40kg", + "대", + "150", + "200", + "275", + "637 (25.5톤 화물차는 풀카고기준)", + "" + ], + [ + "전주", + "10m(일반용)", + "본", + "-", + "-", + "12", + "23", + "" + ], + [ + "체신주 8m", + """, + "", + "17", + "23", + "43", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-02-08", + "number": "1-2-8", + "name": "개소", + "level": 3, + "parent_code": "FP-01-02", + "sort_order": 3584, + "tables": [] + }, + { + "work_item_code": "FP-01-03", + "number": "1-3", + "name": "재료 및 노임의 할증", + "level": 2, + "parent_code": "FP-01", + "sort_order": 3840, + "tables": [] + }, + { + "work_item_code": "FP-01-03-01", + "number": "1-3-1", + "name": "재료의 할증", + "level": 3, + "parent_code": "FP-01-03", + "sort_order": 4096, + "tables": [ + { + "pum_table_id": "F0008", + "section": "1-3-1. 재료의 할증", + "source_line": 857, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "시 멘 트", + "잔골재ㆍ채움재", + "굵 은 골 재", + "아 스 팔 트", + "석 분", + "혼 화 재" + ], + "condition_note": [ + "종 류", + "정 치 식 (%)", + "기 타 (%)" + ], + "raw_row": [ + [ + "시 멘 트", + "2", + "3" + ], + [ + "잔골재ㆍ채움재", + "10", + "12" + ], + [ + "굵 은 골 재", + "3", + "5" + ], + [ + "아 스 팔 트", + "2", + "3" + ], + [ + "석 분", + "2", + "3" + ], + [ + "혼 화 재", + "2", + "-" + ] + ] + }, + { + "pum_table_id": "F0009", + "section": "1-3-1. 재료의 할증", + "source_line": 869, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "모 래", + "부순돌ㆍ자갈ㆍ막자갈", + "석 분", + "점 질 토" + ], + "condition_note": [ + "종 류", + "할 증 률 (%)" + ], + "raw_row": [ + [ + "모 래", + "6" + ], + [ + "부순돌ㆍ자갈ㆍ막자갈", + "4" + ], + [ + "석 분", + "0" + ], + [ + "점 질 토", + "6" + ] + ] + }, + { + "pum_table_id": "F0010", + "section": "1-3-1. 재료의 할증", + "source_line": 878, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "모 래" + ], + "condition_note": [ + "종 류", + "할 증 률 (%)" + ], + "raw_row": [ + [ + "모 래", + "4" + ] + ] + }, + { + "pum_table_id": "F0011", + "section": "1-3-1. 재료의 할증", + "source_line": 884, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "원 형 철 근", + "이 형 철 근", + "이형철근 (교량·지하철 및 이와 유사한 복잡한 구조물의 주철근)", + "강 판", + "강관(옥외 수도용 강관 제외)", + "대형형강 (形 鋼)", + "소 형 형 강", + "봉 강 (棒 鋼)", + "평 강 대 강", + "경량형 강각(角) 파이프", + "리 벳 (제 품)" + ], + "condition_note": [ + "종 류", + "할 증 률 (%)" + ], + "raw_row": [ + [ + "원 형 철 근", + "5" + ], + [ + "이 형 철 근", + "3" + ], + [ + "이형철근 (교량·지하철 및 이와 유사한 복잡한 구조물의 주철근)", + "6-7" + ], + [ + "강 판", + "10" + ], + [ + "강관(옥외 수도용 강관 제외)", + "5" + ], + [ + "대형형강 (形 鋼)", + "7" + ], + [ + "소 형 형 강", + "5" + ], + [ + "봉 강 (棒 鋼)", + "5" + ], + [ + "평 강 대 강", + "5" + ], + [ + "경량형 강각(角) 파이프", + "5" + ], + [ + "리 벳 (제 품)", + "5" + ] + ] + }, + { + "pum_table_id": "F0012", + "section": "1-3-1. 재료의 할증", + "source_line": 901, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "목 재", + "판 재", + "합 판", + "수장용 합판", + "조립식구조물(U형플륨관 등)", + "레디믹스트콘크리트타설 (현장 플랜트 포함)", + "철근 구조물", + "철골 구조물", + "원 석 (마름돌용)", + "사방용 수목", + "떼 및 초화류", + "현장콘크리트 타설 (인력 및 믹서)", + "철근구조물", + "소형구조물", + "아스팔트콘크리트 포설(현장플랜트 포함)", + "콘크리트포장 포설", + "원심력철근콘크리트관" + ], + "condition_note": [ + "재 료 별", + "할 증 률 (%)" + ], + "raw_row": [ + [ + "목 재", + "각 재", + "5" + ], + [ + "판 재", + "10", + "" + ], + [ + "합 판", + "일반용 합판", + "3" + ], + [ + "수장용 합판", + "5", + "" + ], + [ + "조립식구조물(U형플륨관 등)", + "3", + "" + ], + [ + "레디믹스트콘크리트타설 (현장 플랜트 포함)", + "무근 구조물", + "2" + ], + [ + "철근 구조물", + "1", + "" + ], + [ + "철골 구조물", + "1", + "" + ], + [ + "원 석 (마름돌용)", + "30", + "" + ], + [ + "사방용 수목", + "10", + "" + ], + [ + "떼 및 초화류", + "10", + "" + ], + [ + "현장콘크리트 타설 (인력 및 믹서)", + "무근구조물", + "3" + ], + [ + "철근구조물", + "2", + "" + ], + [ + "소형구조물", + "5", + "" + ], + [ + "아스팔트콘크리트 포설(현장플랜트 포함)", + "2", + "" + ], + [ + "콘크리트포장 포설", + "4", + "" + ], + [ + "원심력철근콘크리트관", + "3", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-03-02", + "number": "1-3-2", + "name": "노임의 할증", + "level": 3, + "parent_code": "FP-01-03", + "sort_order": 4352, + "tables": [] + }, + { + "work_item_code": "FP-01-04", + "number": "1-4", + "name": "품의 할인․할증", + "level": 2, + "parent_code": "FP-01", + "sort_order": 4608, + "tables": [] + }, + { + "work_item_code": "FP-01-04-01", + "number": "1-4-1", + "name": "작업시기", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 4864, + "tables": [ + { + "pum_table_id": "F0013", + "section": "1-4-1. 작업시기", + "source_line": 930, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "6월∼9월", + "10월∼12월", + "1∼5월" + ], + "condition_note": [ + "작업시기", + "할증률" + ], + "raw_row": [ + [ + "6월∼9월", + "10%" + ], + [ + "10월∼12월", + "5%" + ], + [ + "1∼5월", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-02", + "number": "1-4-2", + "name": "조림 후 경과연수", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 5120, + "tables": [ + { + "pum_table_id": "F0014", + "section": "1-4-2. 조림 후 경과연수", + "source_line": 941, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "3년차 이상", + "2년차", + "당해 연도(전년도 추기조림 포함)" + ], + "condition_note": [ + "조림 후 경과연수", + "할증률" + ], + "raw_row": [ + [ + "3년차 이상", + "10%" + ], + [ + "2년차", + "5%" + ], + [ + "당해 연도(전년도 추기조림 포함)", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-03", + "number": "1-4-3", + "name": "집단화정도", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 5376, + "tables": [ + { + "pum_table_id": "F0015", + "section": "1-4-3. 집단화정도", + "source_line": 951, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "집단화 정도", + "개소당 평균면적이 1~3ha 미만", + "개소당 평균면적이 3ha 이상" + ], + "condition_note": [ + "구 분", + "집단화 정도", + "할증률" + ], + "raw_row": [ + [ + "집단화 정도", + "개소당 평균면적이 1ha 미만", + "10%" + ], + [ + "개소당 평균면적이 1~3ha 미만", + "5%", + "" + ], + [ + "개소당 평균면적이 3ha 이상", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-04", + "number": "1-4-4", + "name": "작업구역", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 5632, + "tables": [ + { + "pum_table_id": "F0016", + "section": "1-4-4. 작업구역", + "source_line": 966, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "작업구역", + "개소 평균 1~3ha 미만", + "개소 평균 3~5ha 미만", + "개소당 평균 5ha 이상", + "벌채구역 크기", + "개벌지 크기가 3,000㎡(또는 벌채폭 60m) 미만", + "개벌지 크기가 3,000㎡ 이상" + ], + "condition_note": [ + "구 분", + "집단화 정도", + "할인․할증률" + ], + "raw_row": [ + [ + "작업구역", + "개소 평균 1ha 미만", + "10%" + ], + [ + "개소 평균 1~3ha 미만", + "5%", + "" + ], + [ + "개소 평균 3~5ha 미만", + "0%", + "" + ], + [ + "개소당 평균 5ha 이상", + "-5%", + "" + ], + [ + "벌채구역 크기", + "개벌지 크기가 700㎡(또는 벌채폭 30m) 이하", + "10%" + ], + [ + "개벌지 크기가 3,000㎡(또는 벌채폭 60m) 미만", + "5%", + "" + ], + [ + "개벌지 크기가 3,000㎡ 이상", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-05", + "number": "1-4-5", + "name": "경사도", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 5888, + "tables": [ + { + "pum_table_id": "F0017", + "section": "1-4-5. 경사도", + "source_line": 981, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "산지경사", + "중 (15~30°)", + "완 (15° 미만)", + "평균경사도", + "평균경사도 20%~50%미만", + "평균경사도 10%~20%미만", + "평균경사도 10%미만" + ], + "condition_note": [ + "적용기준", + "경사도", + "할인․할증률" + ], + "raw_row": [ + [ + "산지경사", + "급 (30° 초과)", + "10%" + ], + [ + "중 (15~30°)", + "5%", + "" + ], + [ + "완 (15° 미만)", + "0%", + "" + ], + [ + "평균경사도", + "평균경사도 50%이상", + "20%" + ], + [ + "평균경사도 20%~50%미만", + "10%", + "" + ], + [ + "평균경사도 10%~20%미만", + "5%", + "" + ], + [ + "평균경사도 10%미만", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-06", + "number": "1-4-6", + "name": "작업장까지의 이동거리", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 6144, + "tables": [ + { + "pum_table_id": "F0018", + "section": "1-4-6. 작업장까지의 이동거리", + "source_line": 998, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "도 보", + "1.3㎞∼2.5㎞ 미만", + "1.3㎞ 미만", + "차 량" + ], + "condition_note": [ + "구 분", + "작업장까지 이동거리", + "할증률" + ], + "raw_row": [ + [ + "도 보", + "2.5㎞ 이상", + "10%" + ], + [ + "1.3㎞∼2.5㎞ 미만", + "5%", + "" + ], + [ + "1.3㎞ 미만", + "0%", + "" + ], + [ + "차 량", + "2.5㎞ 이상", + "10%" + ], + [ + "1.3㎞∼2.5㎞ 미만", + "5%", + "" + ], + [ + "1.3㎞ 미만", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-07", + "number": "1-4-7", + "name": "하층식생", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 6400, + "tables": [ + { + "pum_table_id": "F0019", + "section": "1-4-7. 하층식생", + "source_line": 1011, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "식생을 제거하지 않고는 보행이 곤란하다", + "보행하는데 약간의 어려움이 있다", + "보행이 용이하다" + ], + "condition_note": [ + "하층식생", + "할증률" + ], + "raw_row": [ + [ + "식생을 제거하지 않고는 보행이 곤란하다", + "10%" + ], + [ + "보행하는데 약간의 어려움이 있다", + "5%" + ], + [ + "보행이 용이하다", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-08", + "number": "1-4-8", + "name": "장애물의 정도", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 6656, + "tables": [ + { + "pum_table_id": "F0020", + "section": "1-4-8. 장애물의 정도", + "source_line": 1019, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "가슴높이 이상의 초본․관목", + "가슴높이 미만의 초본․관목", + "무릎높이 이하의 초본․관목" + ], + "condition_note": [ + "장애물의 정도", + "할증률" + ], + "raw_row": [ + [ + "가슴높이 이상의 초본․관목", + "10%" + ], + [ + "가슴높이 미만의 초본․관목", + "5%" + ], + [ + "무릎높이 이하의 초본․관목", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-09", + "number": "1-4-9", + "name": "제거대상 식생", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 6912, + "tables": [ + { + "pum_table_id": "F0021", + "section": "1-4-9. 제거대상 식생", + "source_line": 1029, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "어렵다(높이 1.2m 이상이고, 직경이 4~6cm 이상)", + "보통이다(높이 1.2m 이상이고, 직경이 4~6cm 미만)", + "쉽다(높이 1.2m 미만이고, 낫으로도 제거가 용이)" + ], + "condition_note": [ + "제거대상 식생", + "할증률" + ], + "raw_row": [ + [ + "어렵다(높이 1.2m 이상이고, 직경이 4~6cm 이상)", + "10%" + ], + [ + "보통이다(높이 1.2m 이상이고, 직경이 4~6cm 미만)", + "5%" + ], + [ + "쉽다(높이 1.2m 미만이고, 낫으로도 제거가 용이)", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-10", + "number": "1-4-10", + "name": "토양상태", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 7168, + "tables": [ + { + "pum_table_id": "F0022", + "section": "1-4-10. 토양상태", + "source_line": 1039, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "돌 등 장애물 함량이 30% 이상이고, 나무뿌리가 밀하게 분포할 경우", + "돌 등 장애물 함량이 10~30%이고, 나무뿌리가 보통정도로 분포할 경우", + "식혈시 장애물이 거의 없음" + ], + "condition_note": [ + "토양조건", + "할증률" + ], + "raw_row": [ + [ + "돌 등 장애물 함량이 30% 이상이고, 나무뿌리가 밀하게 분포할 경우", + "10%" + ], + [ + "돌 등 장애물 함량이 10~30%이고, 나무뿌리가 보통정도로 분포할 경우", + "5%" + ], + [ + "식혈시 장애물이 거의 없음", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-11", + "number": "1-4-11", + "name": "덩굴피복도", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 7424, + "tables": [ + { + "pum_table_id": "F0023", + "section": "1-4-11. 덩굴피복도", + "source_line": 1049, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "과밀(80%초과 피복)", + "밀(60~80%미만 피복)", + "보통(40~60%미만 피복)", + "소(20~40%미만 피복)", + "과소(20%미만 피복)" + ], + "condition_note": [ + "덩굴 피복도", + "할인․할증률" + ], + "raw_row": [ + [ + "과밀(80%초과 피복)", + "50%" + ], + [ + "밀(60~80%미만 피복)", + "20%" + ], + [ + "보통(40~60%미만 피복)", + "0%" + ], + [ + "소(20~40%미만 피복)", + "-20%" + ], + [ + "과소(20%미만 피복)", + "-50%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-12", + "number": "1-4-12", + "name": "제거대상 피복도", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 7680, + "tables": [ + { + "pum_table_id": "F0024", + "section": "1-4-12. 제거대상 피복도", + "source_line": 1061, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "대 (임지의 71% 이상 분포)", + "중 (임지의 41%~70% 분포)", + "소 (임지의 40% 이하 분포)" + ], + "condition_note": [ + "제거대상 피복도", + "할인․할증률" + ], + "raw_row": [ + [ + "대 (임지의 71% 이상 분포)", + "20%" + ], + [ + "중 (임지의 41%~70% 분포)", + "0%" + ], + [ + "소 (임지의 40% 이하 분포)", + "-20%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-13", + "number": "1-4-13", + "name": "주행 장애물 상태", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 7936, + "tables": [ + { + "pum_table_id": "F0025", + "section": "1-4-13. 주행 장애물 상태", + "source_line": 1073, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "돌, 도랑, 그루터기 등으로 주행이 매우 힘들다", + "돌, 도랑, 그루터기 등으로 주행이 다소 힘들다", + "주행에 어려움이 없다" + ], + "condition_note": [ + "주행 장애물 상태", + "할증률" + ], + "raw_row": [ + [ + "돌, 도랑, 그루터기 등으로 주행이 매우 힘들다", + "10%" + ], + [ + "돌, 도랑, 그루터기 등으로 주행이 다소 힘들다", + "5%" + ], + [ + "주행에 어려움이 없다", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-14", + "number": "1-4-14", + "name": "집재방향", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 8192, + "tables": [ + { + "pum_table_id": "F0026", + "section": "1-4-14. 집재방향", + "source_line": 1081, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "가선을 이용한 기계장비의 원목․생산재 집재", + "상향집재", + "소나무재선충병 인력에 의한 원목 및 재해산물 수집", + "중(15~30°)", + "완(15° 미만)", + "소나무재선충병 방제 훈증더미 제거", + "수평 또는 하향집재" + ], + "condition_note": [ + "구분", + "집재방향", + "할증률" + ], + "raw_row": [ + [ + "가선을 이용한 기계장비의 원목․생산재 집재", + "하향집재", + "10%", + "" + ], + [ + "상향집재", + "0%", + "", + "" + ], + [ + "소나무재선충병 인력에 의한 원목 및 재해산물 수집", + "상향집재", + "급(30°초과)", + "10%" + ], + [ + "중(15~30°)", + "5%", + "", + "" + ], + [ + "완(15° 미만)", + "0%", + "", + "" + ], + [ + "소나무재선충병 방제 훈증더미 제거", + "상향집재", + "10%", + "" + ], + [ + "수평 또는 하향집재", + "0%", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-15", + "number": "1-4-15", + "name": "횡단운반거리", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 8448, + "tables": [ + { + "pum_table_id": "F0027", + "section": "1-4-15. 횡단운반거리", + "source_line": 1095, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "11~20m", + "6~10m", + "0~5m" + ], + "condition_note": [ + "횡단운반거리", + "할증률" + ], + "raw_row": [ + [ + "11~20m", + "10%" + ], + [ + "6~10m", + "5%" + ], + [ + "0~5m", + "0%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-16", + "number": "1-4-16", + "name": "규격재 생산", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 8704, + "tables": [ + { + "pum_table_id": "F0028", + "section": "1-4-16. 규격재 생산", + "source_line": 1105, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "규격재 생산을 위한 조재" + ], + "condition_note": [ + "적용 조건", + "할증률" + ], + "raw_row": [ + [ + "규격재 생산을 위한 조재", + "20%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-17", + "number": "1-4-17", + "name": "방제대상목 분포", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 8960, + "tables": [ + { + "pum_table_id": "F0029", + "section": "1-4-17. 방제대상목 분포", + "source_line": 1111, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "산림병해충방제 (단목베기, 나무주사)", + "10~29본/ha", + "30~49본/ha", + "50본/ha 이상", + "소나무재선충병방제 (소각, 매몰, 훈증, 박피)", + "15본~25본/ha", + "25본~35본/ha", + "35본~45본/ha", + "45본/ha 이상" + ], + "condition_note": [ + "구 분", + "대상목 분포", + "할인․할증률" + ], + "raw_row": [ + [ + "산림병해충방제 (단목베기, 나무주사)", + "10본/ha 미만", + "30%" + ], + [ + "10~29본/ha", + "20%", + "" + ], + [ + "30~49본/ha", + "10%", + "" + ], + [ + "50본/ha 이상", + "0", + "" + ], + [ + "소나무재선충병방제 (소각, 매몰, 훈증, 박피)", + "15본/ha 이하", + "20%" + ], + [ + "15본~25본/ha", + "10%", + "" + ], + [ + "25본~35본/ha", + "0%", + "" + ], + [ + "35본~45본/ha", + "-10%", + "" + ], + [ + "45본/ha 이상", + "-20%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-18", + "number": "1-4-18", + "name": "방제대상목 평균 경급", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 9216, + "tables": [ + { + "pum_table_id": "F0030", + "section": "1-4-18. 방제대상목 평균 경급", + "source_line": 1125, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "산림병해충방제 (산물수집-기계장비 집재)", + "16~20cm", + "소나무재선충병방제 (그물망 피복)", + "18cm", + "20cm 이상" + ], + "condition_note": [ + "구분", + "평균경급", + "할인․할증률" + ], + "raw_row": [ + [ + "산림병해충방제 (산물수집-기계장비 집재)", + "22~26cm", + "10%" + ], + [ + "16~20cm", + "0%", + "" + ], + [ + "소나무재선충병방제 (그물망 피복)", + "16cm 이하", + "10%" + ], + [ + "18cm", + "0%", + "" + ], + [ + "20cm 이상", + "-10%", + "" + ] + ] + }, + { + "pum_table_id": "F0031", + "section": "1-4-18. 방제대상목 평균 경급", + "source_line": 1135, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "매개충나무주사" + ], + "condition_note": [ + "구분", + "내 용", + "할증률" + ], + "raw_row": [ + [ + "매개충나무주사", + "매개충나무주사 실행", + "30%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-19", + "number": "1-4-19", + "name": "매개충나무주사", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 9472, + "tables": [] + }, + { + "work_item_code": "FP-01-04-20", + "number": "1-4-20", + "name": "방제지 사면", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 9728, + "tables": [ + { + "pum_table_id": "F0032", + "section": "1-4-20. 방제지 사면", + "source_line": 1143, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "사면형", + "종방향 복합사면(가시거리 불량, 고소작업차 사용 필요)", + "횡방향 복합사면(가시거리 보통)", + "단순사면(가시거리 양호)" + ], + "condition_note": [ + "구 분", + "내 용", + "할인․할증률" + ], + "raw_row": [ + [ + "사면형", + "종․횡방향 복합사면(가시거리 매우불량, 고소작업차 이동 필요)", + "10%" + ], + [ + "종방향 복합사면(가시거리 불량, 고소작업차 사용 필요)", + "5%", + "" + ], + [ + "횡방향 복합사면(가시거리 보통)", + "0%", + "" + ], + [ + "단순사면(가시거리 양호)", + "-5%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-21", + "number": "1-4-21", + "name": "방제지 접근성", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 9984, + "tables": [ + { + "pum_table_id": "F0033", + "section": "1-4-21. 방제지 접근성", + "source_line": 1155, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "접근성", + "이착륙장~방재사업지까지의 이동거리 200m 이내 까지 차량접근 가능", + "이착륙장~방재사업지까지의 이동거리 100m 이내 까지 차량접근 가능" + ], + "condition_note": [ + "구 분", + "내 용", + "할증률" + ], + "raw_row": [ + [ + "접근성", + "이착륙장~방재사업지까지의 이동거리 200m 이상 거리까지 차량접근 가능", + "20%" + ], + [ + "이착륙장~방재사업지까지의 이동거리 200m 이내 까지 차량접근 가능", + "10%", + "" + ], + [ + "이착륙장~방재사업지까지의 이동거리 100m 이내 까지 차량접근 가능", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-22", + "number": "1-4-22", + "name": "방제수종", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 10240, + "tables": [ + { + "pum_table_id": "F0034", + "section": "1-4-22. 방제 수종", + "source_line": 1168, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "방제 수종", + "소나무, 해송" + ], + "condition_note": [ + "구 분", + "내 용", + "할증률" + ], + "raw_row": [ + [ + "방제 수종", + "잣나무", + "20%" + ], + [ + "소나무, 해송", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-23", + "number": "1-4-23", + "name": "방제목 운반거리", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 10496, + "tables": [ + { + "pum_table_id": "F0035", + "section": "1-4-23. 방제목 운반거리", + "source_line": 1175, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "그물망 피복 시 임내 운반거리", + "101~200m", + "100m 이하", + "수라집재 시 목재 운반거리", + "6~10m", + "0~5m" + ], + "condition_note": [ + "구 분", + "내 용", + "할증률" + ], + "raw_row": [ + [ + "그물망 피복 시 임내 운반거리", + "200m 초과", + "20%" + ], + [ + "101~200m", + "10%", + "" + ], + [ + "100m 이하", + "0%", + "" + ], + [ + "수라집재 시 목재 운반거리", + "11~20m", + "10%" + ], + [ + "6~10m", + "5%", + "" + ], + [ + "0~5m", + "0%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-24", + "number": "1-4-24", + "name": "방제장비 규격", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 10752, + "tables": [ + { + "pum_table_id": "F0036", + "section": "1-4-24. 방제장비 규격", + "source_line": 1188, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "장비의 규격 (굴삭기)", + "0.4㎥ 이상" + ], + "condition_note": [ + "구 분", + "내 용", + "할인률" + ], + "raw_row": [ + [ + "장비의 규격 (굴삭기)", + "0.2㎥", + "0" + ], + [ + "0.4㎥ 이상", + "장비품의 -20%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-25", + "number": "1-4-25", + "name": "작업시간 제한", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 11008, + "tables": [ + { + "pum_table_id": "F0037", + "section": "1-4-25. 작업시간 제한", + "source_line": 1197, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업 가능 시간", + "3시간 이하", + "4시간 이하", + "5시간 이하", + "6시간 이하" + ], + "condition_note": [ + "구 분", + "적용조건", + "할증률" + ], + "raw_row": [ + [ + "작업 가능 시간", + "2시간 이하", + "50%" + ], + [ + "3시간 이하", + "35%", + "" + ], + [ + "4시간 이하", + "25%", + "" + ], + [ + "5시간 이하", + "20%", + "" + ], + [ + "6시간 이하", + "15%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-04-26", + "number": "1-4-26", + "name": "소규모 작업물량 제한", + "level": 3, + "parent_code": "FP-01-04", + "sort_order": 11264, + "tables": [ + { + "pum_table_id": "F0038", + "section": "1-4-26. 소규모 작업물량 제한", + "source_line": 1212, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "1", + "2" + ], + "condition_note": [ + "구분", + "조 건", + "적용시공량" + ], + "raw_row": [ + [ + "1", + "A ≦ B/2 일 경우", + "Q = B/2", + "" + ], + [ + "2", + "B/2 < A ≦ B 일 경우", + "Q = B", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-05", + "number": "1-5", + "name": "목재 원목 시가", + "level": 2, + "parent_code": "FP-01", + "sort_order": 11520, + "tables": [] + }, + { + "work_item_code": "FP-01-06", + "number": "1-6", + "name": "원가 작성기준", + "level": 2, + "parent_code": "FP-01", + "sort_order": 11776, + "tables": [ + { + "pum_table_id": "F0039", + "section": "1-6. 원가 작성기준", + "source_line": 1231, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "적용방법", + "순 원 가", + "간 접 재 료 비", + "소 계", + "노 무 비", + "간 접 노 무 비", + "경 비", + "산 업 재 해 보 상 보 험 료", + "고 용 보 험 료", + "국 민 건 강 보 험 료", + "국 민 연 금 보 험 료", + "노 인 장 기 요 양 보 험 료", + "산 업 안 전 보 건 관 리 비", + "기 타 경 비", + "기 타 법 정 경 비", + "일 반 관 리 비", + "이 윤", + "총 원 가", + "부 가 가 치 세", + "합 계" + ], + "condition_note": [ + "구분 비목", + "적용 기준" + ], + "raw_row": [ + [ + "적용방법", + "요율", + "적용 기준", + "", + "", + "" + ], + [ + "순 원 가", + "재 료 비", + "직 접 재 료 비", + "주재료비+잡품", + "", + "산림사업 표준품셈 적용기준" + ], + [ + "간 접 재 료 비", + "", + "", + "", + "", + "" + ], + [ + "소 계", + "", + "", + "", + "", + "" + ], + [ + "노 무 비", + "직 접 노 무 비", + "", + "", + "산림사업 표준품셈 적용기준", + "" + ], + [ + "간 접 노 무 비", + "(직접노무비)×율", + "", + "토목·조경·산업환경설비공사 원가계산 제비율 적용기준", + "", + "" + ], + [ + "소 계", + "", + "", + "", + "", + "" + ], + [ + "경 비", + "기 계 경 비", + "기계손료×장비단가", + "", + "산림사업 표준품셈 적용기준", + "" + ], + [ + "산 업 재 해 보 상 보 험 료", + "(노무비 : 직노+간노)×율", + "", + "사업종류별 산재보험료율 적용", + "", + "" + ], + [ + "고 용 보 험 료", + "(노무비)×율", + "", + "관련 법령의 보험요율 적용", + "", + "" + ], + [ + "국 민 건 강 보 험 료", + "(직접노무비)×율", + "", + "", + "", + "" + ], + [ + "국 민 연 금 보 험 료", + "(직접노무비)×율", + "", + "", + "", + "" + ], + [ + "노 인 장 기 요 양 보 험 료", + "(건강보험료)×율", + "", + "", + "", + "" + ], + [ + "산 업 안 전 보 건 관 리 비", + "(재료비+직접노무비)×율", + "", + "건설업산업안전관리비계상 및 사용기준 또는 산림청에서 적용하는 지정 요율 적용", + "", + "" + ], + [ + "기 타 경 비", + "(재료비+노무비)×율", + "", + "토목·조경·산업환경설비공사 원가계산 제비율 적용기준", + "", + "" + ], + [ + "기 타 법 정 경 비", + "", + "", + "기타 법정경비 발생 시 적용", + "", + "" + ], + [ + "소 계", + "", + "", + "", + "", + "" + ], + [ + "일 반 관 리 비", + "(재료비+노무비+경비)× 율", + "", + "토목·조경·산업환경설비공사 원가계산 제비율 적용기준", + "", + "" + ], + [ + "이 윤", + "(노무비+경비+일반관리비)× 율", + "", + "토목·조경·산업환경설비공사 원가계산 제비율 적용기준", + "", + "" + ], + [ + "총 원 가", + "", + "", + "", + "", + "" + ], + [ + "부 가 가 치 세", + "(총원가)×율", + "10%", + "부가가치세법", + "", + "" + ], + [ + "합 계", + "총원가+부가가치세", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-07", + "number": "1-7", + "name": "기타사항", + "level": 2, + "parent_code": "FP-01", + "sort_order": 12032, + "tables": [] + }, + { + "work_item_code": "FP-01-07-01", + "number": "1-7-1", + "name": "거푸집 사용", + "level": 3, + "parent_code": "FP-01-07", + "sort_order": 12288, + "tables": [ + { + "pum_table_id": "F0040", + "section": "1-7-1. 거푸집 사용", + "source_line": 1266, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "2회", + "3회", + "4회", + "6회" + ], + "condition_note": [ + "사용횟수", + "구 조 물" + ], + "raw_row": [ + [ + "2회", + "T형보, 난간, 특히 복잡한 구조의 교각, 교대, 수문관의 본체 등 복잡한 구조" + ], + [ + "3회", + "슬래브, 교대, 교각, 옹벽, 파라펫트, 날개벽 등 약간 복잡한 구조" + ], + [ + "4회", + "측구, 수로, 확대기초, 우물통 등 비교적 간단한 구조" + ], + [ + "6회", + "수문 또는 관의 기초, 호안 및 보호공의 기초 등 극히 간단한 구조" + ] + ] + } + ] + }, + { + "work_item_code": "FP-01-07-02", + "number": "1-7-2", + "name": "분해 및 조립비", + "level": 3, + "parent_code": "FP-01-07", + "sort_order": 12544, + "tables": [] + }, + { + "work_item_code": "FP-01-07-03", + "number": "1-7-3", + "name": "사용료", + "level": 3, + "parent_code": "FP-01-07", + "sort_order": 12800, + "tables": [] + }, + { + "work_item_code": "FP-01-07-04", + "number": "1-7-4", + "name": "공사용수", + "level": 3, + "parent_code": "FP-01-07", + "sort_order": 13056, + "tables": [ + { + "pum_table_id": "F0041", + "section": "1-7-4. 공사용수", + "source_line": 1286, + "pum_form": "reference", + "form_basis": "품셈 제1장(적용기준)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "거 푸 집 씻 기", + "콘크리트혼합 및 양생", + "돌 쌓 기 모 르 타 르", + "돌 씻 기", + "모래씻기", + "잡 용 수" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "거 푸 집 씻 기", + "㎥/㎡", + "0.04" + ], + [ + "콘크리트혼합 및 양생", + "㎥/㎥", + "0.27" + ], + [ + "돌 쌓 기 모 르 타 르", + "㎥/㎡ (표면적)", + "0.06" + ], + [ + "돌 씻 기", + "㎥/㎡ (표면적)", + "0.17" + ], + [ + "모래씻기", + "㎥/㎥", + "0.25" + ], + [ + "잡 용 수", + "㎥", + "사용량비의 40∼50%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02", + "number": "2", + "name": "소요재료 및 기계손료", + "level": 1, + "parent_code": null, + "sort_order": 13312, + "tables": [] + }, + { + "work_item_code": "FP-02-01", + "number": "2-1", + "name": "소요재료", + "level": 2, + "parent_code": "FP-02", + "sort_order": 13568, + "tables": [ + { + "pum_table_id": "F0455", + "section": "2-1. 풀베기, (1) 둘레베기(조림목 본수 2,700본/ha, 조림1년차)", + "source_line": 8031, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "+10%" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+007E)", + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 면적", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ 둘레베기", + "합 계", + "직접노무비", + "재 료 비", + "경비(기계경비)", + "<할인․할증률 적용>", + "구 분", + "둘레베기", + "o 집단화 정도(1-4-3)", + "o 경사도(1-4-5)" + ], + "condition_note": [ + "ha당 풀베기(둘레베기) 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 둘레베기", + "", + "", + "", + "", + "", + "", + "", + "", + "907,772", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "907,772", + "", + "" + ], + [ + "ㆍ 둘레베기", + "2,700", + "본/㏊", + "0.18", + "인/100본", + "4.86", + "169,804", + "보통100%", + "+10%", + "907,772", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "907,772", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "907,772", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "-", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "-", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "둘레베기", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 집단화 정도(1-4-3)", + "개소당 평균면적이 1~3ha 미만", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "중경사(15∼30°)", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "10%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-01", + "number": "2-1-1", + "name": "휘발유․오일", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 13824, + "tables": [ + { + "pum_table_id": "F0042", + "section": "2-1-1. 휘발유․오일", + "source_line": 1305, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통휘발유 (주연료)", + "체인오일 (일반오일)", + "체인오일 (친환경오일)" + ], + "condition_note": [ + "재료명", + "소요량 (ℓ/대/일)", + "잡품 (주연료비의 %)", + "적용기준" + ], + "raw_row": [ + [ + "보통휘발유 (주연료)", + "5.6", + "40%", + "∙ 체인톱 대수는 산출된 벌목부 또는 특별인부의 100% 적용" + ], + [ + "체인오일 (일반오일)", + "2.1", + "", + "∙ 시중가격 적용" + ], + [ + "체인오일 (친환경오일)", + "2.1", + "", + "∙ 시중가격 적용" + ] + ] + }, + { + "pum_table_id": "F0043", + "section": "2-1-1. 휘발유․오일", + "source_line": 1328, + "pum_form": "requirement", + "form_basis": "'소요인력'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "예취기(휘발유)" + ], + "condition_note": [ + "기계명(주재료)", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)", + "소요인력당 적용기준" + ], + "raw_row": [ + [ + "예취기(휘발유)", + "5.0", + "10%", + "∙ 1대당 1인 작업" + ] + ] + }, + { + "pum_table_id": "F0044", + "section": "2-1-1. 휘발유․오일", + "source_line": 1340, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "아키아윈치(휘발유)" + ], + "condition_note": [ + "기계명(주재료)", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)" + ], + "raw_row": [ + [ + "아키아윈치(휘발유)", + "6.5", + "30" + ] + ] + }, + { + "pum_table_id": "F0045", + "section": "2-1-1. 휘발유․오일", + "source_line": 1346, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "2드럼 케이블윈치(휘발유)" + ], + "condition_note": [ + "기계명(주재료)", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)" + ], + "raw_row": [ + [ + "2드럼 케이블윈치(휘발유)", + "9.8", + "30" + ] + ] + }, + { + "pum_table_id": "F0046", + "section": "2-1-1. 휘발유․오일", + "source_line": 1353, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "인", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "천공기 (휘발유)" + ], + "condition_note": [ + "기계명 (주재료)", + "주연료 (ℓ/일,대)", + "잡품 (주연료의 %)", + "적용기준" + ], + "raw_row": [ + [ + "천공기 (휘발유)", + "3", + "95%", + "∙ 천공인부 1인당 1대 적용 ∙ 잡품은 연료비에 대하여 금액비율로 적용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-02", + "number": "2-1-2", + "name": "경유", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 14080, + "tables": [ + { + "pum_table_id": "F0047", + "section": "2-1-2. 경유", + "source_line": 1361, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "트랙터 부착 기계", + "굴삭기 부착 기계", + "타워야더(RME 300T)", + "콜라타워야더(K-301)", + "소형 포워더", + "무한궤도형 임내차", + "HAM300(임업용 트랙터 포함)", + "하베스터(헤드부착형)", + "스윙야더", + "소형트럭", + "우드그래플", + "임업용동력집재기" + ], + "condition_note": [ + "기계명", + "주연료 (ℓ/일,대)", + "잡품 (주연료비의 %)", + "적용기준" + ], + "raw_row": [ + [ + "트랙터 부착 기계", + "26.0", + "40", + "" + ], + [ + "굴삭기 부착 기계", + "20.8", + "30", + "" + ], + [ + "타워야더(RME 300T)", + "32.5", + "40", + "" + ], + [ + "콜라타워야더(K-301)", + "39.0", + "40", + "" + ], + [ + "소형 포워더", + "26.0", + "30", + "" + ], + [ + "무한궤도형 임내차", + "34.8", + "30", + "" + ], + [ + "HAM300(임업용 트랙터 포함)", + "42.0", + "40", + "" + ], + [ + "하베스터(헤드부착형)", + "81.6", + "40", + "0.6㎥이상의 무한궤도형 굴착기를 기준" + ], + [ + "스윙야더", + "30.0", + "40", + "0.2㎥이상의 무한궤도형 굴착기를 기준" + ], + [ + "소형트럭", + "15.0", + "30", + "" + ], + [ + "우드그래플", + "20.8", + "30", + "" + ], + [ + "임업용동력집재기", + "20.8", + "30", + "트랙터 부착형 (임업용 트랙터 포함)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-03", + "number": "2-1-3", + "name": "우드그래플 소모품", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 14336, + "tables": [ + { + "pum_table_id": "F0048", + "section": "2-1-3. 우드그래플 소모품", + "source_line": 1387, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "트랙 접지력 보강", + "블레이드 및 실린더 교체" + ], + "condition_note": [ + "품 명", + "소요비용(원)" + ], + "raw_row": [ + [ + "트랙 접지력 보강", + "800,000" + ], + [ + "블레이드 및 실린더 교체", + "3,000,000" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-04", + "number": "2-1-4", + "name": "페인트 및 마킹테이프", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 14592, + "tables": [ + { + "pum_table_id": "F0049", + "section": "2-1-4. 페인트 및 마킹테이프", + "source_line": 1402, + "pum_form": "requirement", + "form_basis": "'소요량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "주재료", + "페인트", + "마킹테이프" + ], + "condition_note": [ + "재료명", + "재료비(1km 소요량기준)", + "비 고" + ], + "raw_row": [ + [ + "주재료", + "잡품 (주재료비의 %)", + "적용기준", + "", + "" + ], + [ + "페인트", + "0.5ℓ", + "5%", + "∙ 친환경성 백색 수성페인트 사용 ∙ 10ℓ/1통 경우 : 0.05통 적용", + "사용재료에 따라 선정" + ], + [ + "마킹테이프", + "60m", + "-", + "∙ 임업용 백색 마킹테이프 사용 ∙ 75m/1롤 경우 : 0.8롤 적용", + "" + ] + ] + }, + { + "pum_table_id": "F0050", + "section": "2-1-4. 페인트 및 마킹테이프", + "source_line": 1417, + "pum_form": "requirement", + "form_basis": "'소요량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "주재료", + "페인트" + ], + "condition_note": [ + "재료명", + "재료비 (ha당 소요량)", + "비 고" + ], + "raw_row": [ + [ + "주재료", + "잡품 (주재료비의 %)", + "적용기준", + "", + "" + ], + [ + "페인트", + "0.2ℓ", + "5%", + "∙ 친환경성 수성페인트 사용 ∙ 10ℓ/1통 경우 : 0.02통 적용", + "사용재료에 따라 선정" + ] + ] + }, + { + "pum_table_id": "F0051", + "section": "2-1-4. 페인트 및 마킹테이프", + "source_line": 1430, + "pum_form": "requirement", + "form_basis": "'소요량'", + "basis_quantity": 1.0, + "basis_unit": "km", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "주재료", + "마킹테이프" + ], + "condition_note": [ + "재료명", + "재료비 (1km당 소요량)", + "비 고" + ], + "raw_row": [ + [ + "주재료", + "적용기준", + "", + "" + ], + [ + "마킹테이프", + "2.18Roll", + "∙ 임업용 적색 마킹테이프 사용 ∙ 1Roll = 55m", + "" + ] + ] + }, + { + "pum_table_id": "F0052", + "section": "2-1-4. 페인트 및 마킹테이프", + "source_line": 1444, + "pum_form": "requirement", + "form_basis": "'소요량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "주재료", + "페인트" + ], + "condition_note": [ + "재료명", + "재료비 (ha당 소요량)", + "비 고" + ], + "raw_row": [ + [ + "주재료", + "잡품 (주재료비의 %)", + "적용기준", + "", + "" + ], + [ + "페인트", + "0.1ℓ", + "5%", + "∙ 친환경성 백색 수성페인트 사용 ∙ 10ℓ/1통 경우 : 0.01통 적용", + "사용재료에 따라 선정" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-05", + "number": "2-1-5", + "name": "약제(농약) 및 친환경비닐랩", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 14848, + "tables": [ + { + "pum_table_id": "F0053", + "section": "2-1-5. 약제(농약) 및 친환경비닐랩", + "source_line": 1459, + "pum_form": "requirement", + "form_basis": "'소요인력'", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "x(U+0078)" + ], + "capacity_formula_here": false, + "variant_key": [ + "농약 (Fluroxypyr -meptyl + Triclypyr-TEA 미탁제)" + ], + "condition_note": [ + "재료명", + "주재료(병)", + "잡품(주재료비의 %)", + "소요인력당 적용기준" + ], + "raw_row": [ + [ + "농약 (Fluroxypyr -meptyl + Triclypyr-TEA 미탁제)", + "10", + "10%", + "∙ 1ha당 농약 소요량" + ] + ] + }, + { + "pum_table_id": "F0054", + "section": "2-1-5. 약제(농약) 및 친환경비닐랩", + "source_line": 1477, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소금처리량(g)" + ], + "condition_note": [ + "주두부직경(㎝)", + "2㎝미만", + "2~6㎝미만", + "6~8㎝미만", + "8㎝이상" + ], + "raw_row": [ + [ + "소금처리량(g)", + "20", + "40", + "60", + "80" + ] + ] + }, + { + "pum_table_id": "F0055", + "section": "2-1-5. 약제(농약) 및 친환경비닐랩", + "source_line": 1486, + "pum_form": "requirement", + "form_basis": "'소요량'", + "basis_quantity": 100.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "농약 (글리포세이트)", + "친환경 비닐랩" + ], + "condition_note": [ + "재료명", + "주재료 (병, 매, kg)", + "잡품 (주재료비의 %)", + "산출기준" + ], + "raw_row": [ + [ + "농약 (글리포세이트)", + "1.5", + "66%", + "∙ 100본당 농약 소요량" + ], + [ + "친환경 비닐랩", + "300", + "66%", + "∙ 100본당 비닐 소요량" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-06", + "number": "2-1-6", + "name": "나무주사(100본당)", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 15104, + "tables": [ + { + "pum_table_id": "F0056", + "section": "2-1-6. 나무주사(100본당)", + "source_line": 1500, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": 100.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "수량", + "천공테이프", + "흰색 페인트 (친환경성)", + "표식라벨", + "천공기날", + "약재주입기", + "방제복", + "약제배낭" + ], + "condition_note": [ + "구 분", + "주 재 료", + "잡 품" + ], + "raw_row": [ + [ + "수량", + "적용기준", + "", + "" + ], + [ + "천공테이프", + "60m", + "본당 0.6m 소요", + "-" + ], + [ + "흰색 페인트 (친환경성)", + "5ℓ", + "본당 5㎖", + "주재료비의 5% (페인트 붓 소모량)" + ], + [ + "표식라벨", + "100개", + "본당 1개(규격 10cm×7cm)", + "" + ], + [ + "천공기날", + "0.074", + "ha당 0.5개 소요", + "" + ], + [ + "약재주입기", + "0.015", + "ha당 0.1개 소요", + "" + ], + [ + "방제복", + "0.015", + "ha당 0.1개 소요", + "" + ], + [ + "약제배낭", + "0.015", + "ha당 0.1개 소요", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-07", + "number": "2-1-7", + "name": "소각, 매몰, 훈증, 박피", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 15360, + "tables": [ + { + "pum_table_id": "F0057", + "section": "2-1-7. 소각, 매몰, 훈증, 박피", + "source_line": 1518, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "훈증약제", + "훈증피복제", + "표식라벨", + "벌근훈증약제", + "벌근피복제" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "훈증약제", + "ℓ", + "층적부피에 따라 별도계산", + "" + ], + [ + "훈증피복제", + "개", + "〃", + "" + ], + [ + "표식라벨", + "개", + "〃", + "" + ], + [ + "벌근훈증약제", + "ℓ", + "5", + "" + ], + [ + "벌근피복제", + "개", + "100", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-08", + "number": "2-1-8", + "name": "유인 헬기(160ha당)", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 15616, + "tables": [ + { + "pum_table_id": "F0058", + "section": "2-1-8. 유인 헬기(160ha당)", + "source_line": 1535, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 160.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "수 량", + "휘발유 (양수기)", + "깃 발" + ], + "condition_note": [ + "구 분", + "주 재 료", + "잡 품" + ], + "raw_row": [ + [ + "수 량", + "적용기준", + "", + "" + ], + [ + "휘발유 (양수기)", + "10ℓ", + "5시간(시간당 2ℓ)", + "주재료비의 95%" + ], + [ + "깃 발", + "8개", + "20ha당 1개 (깃발 천재질, 코팅, 박음마감)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-09", + "number": "2-1-9", + "name": "드론 무인 헬리콥터(ha당)", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 15872, + "tables": [ + { + "pum_table_id": "F0059", + "section": "2-1-9. 드론 무인 헬리콥터(ha당)", + "source_line": 1548, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "수 량", + "휘발유 (연료)", + "잡품" + ], + "condition_note": [ + "구 분", + "주 재 료", + "비 고" + ], + "raw_row": [ + [ + "수 량", + "적용기준", + "", + "" + ], + [ + "휘발유 (연료)", + "1.13ℓ", + "1시간 (시간당 2.5ℓ)", + "한국석유공사 보통휘발유 설계 전월 평균가격" + ], + [ + "잡품", + "주재료비의 45%", + "윤활유, 주입기, 약통 등", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-10", + "number": "2-1-10", + "name": "드론 무인 멀티콥터(ha당)", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 16128, + "tables": [ + { + "pum_table_id": "F0060", + "section": "2-1-10. 드론 무인 멀티콥터(ha당)", + "source_line": 1559, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "수 량", + "휘발유(발전기)", + "잡품" + ], + "condition_note": [ + "구 분", + "주 재 료", + "비 고" + ], + "raw_row": [ + [ + "수 량", + "적용기준", + "", + "" + ], + [ + "휘발유(발전기)", + "0.81ℓ", + "시간(시간당 1ℓ)", + "" + ], + [ + "잡품", + "30%", + "발전기 엔진오일, 프로펠러 소모 등", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-11", + "number": "2-1-11", + "name": "지상 약제살포", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 16384, + "tables": [ + { + "pum_table_id": "F0061", + "section": "2-1-11. 지상 약제살포", + "source_line": 1570, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "수 량", + "경유(차량살포)" + ], + "condition_note": [ + "구 분", + "주 재 료", + "잡 품" + ], + "raw_row": [ + [ + "수 량", + "적용기준", + "", + "" + ], + [ + "경유(차량살포)", + "1.3ℓ", + "15ha 20ℓ 소요", + "주재료비의 5%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-12", + "number": "2-1-12", + "name": "그물망 피복", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 16640, + "tables": [ + { + "pum_table_id": "F0062", + "section": "2-1-12. 그물망 피복", + "source_line": 1579, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "그물망", + "표식라벨" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "그물망", + "개", + "층적부피에 따라 별도계산" + ], + [ + "표식라벨", + "개", + "〃" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-01-13", + "number": "2-1-13", + "name": "파쇄", + "level": 3, + "parent_code": "FP-02-01", + "sort_order": 16896, + "tables": [ + { + "pum_table_id": "F0063", + "section": "2-1-13. 파쇄", + "source_line": 1586, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "메인파쇄기날", + "분쇄기날" + ], + "condition_note": [ + "소모품", + "소모율", + "가격(원)", + "기타" + ], + "raw_row": [ + [ + "메인파쇄기날", + "0.00125개/hr", + "-", + "" + ], + [ + "분쇄기날", + "0.005개/hr", + "-", + "42개 사용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02", + "number": "2-2", + "name": "기계손료", + "level": 2, + "parent_code": "FP-02", + "sort_order": 17152, + "tables": [ + { + "pum_table_id": "F0456", + "section": "2-2. 풀베기, (2) 모두베기(조림목 본수 2,700본/ha, 조림2년차)", + "source_line": 8061, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "+10%" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+007E)", + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 면적", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ 묘목찾기", + "2. 모두베기", + "ㆍ 예취기 사용", + "- 재료비(예취기)", + "ㆍ보통휘발유(주연료)", + "ㆍ보통휘발유(잡품)", + "- 기계경비(예취기)", + "합 계", + "직접노무비", + "재 료 비", + "경비(기계경비)", + "<할인․할증률 적용>", + "구 분", + "묘목찾기", + "o 집단화 정도(1-4-3)", + "o 경사도(1-4-5)" + ], + "condition_note": [ + "ha당 풀베기(모두베기) 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 묘목찾기", + "", + "", + "", + "", + "", + "", + "", + "", + "504,317", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "504,317", + "", + "" + ], + [ + "ㆍ 묘목찾기", + "2,700", + "본/㏊", + "0.10", + "인/100본", + "2.70", + "169,804", + "보통100%", + "+10%", + "504,317", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "2. 모두베기", + "", + "", + "", + "", + "", + "", + "", + "", + "755,335", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "755,335", + "", + "" + ], + [ + "ㆍ 예취기 사용", + "1.0", + "ha", + "3.10", + "인/ha", + "3.10", + "221,506", + "특별100%", + "+10%", + "755,335", + "", + "" + ], + [ + "- 재료비(예취기)", + "", + "", + "", + "", + "", + "", + "", + "", + "26,205", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "3.10", + "대/㏊", + "5.00", + "ℓ/대", + "15.50", + "1,537", + "무연(ℓ)", + "", + "23,823", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "23,823", + "원", + "10", + "%", + "", + "", + "", + "", + "2,382", + "", + "" + ], + [ + "- 기계경비(예취기)", + "3.10", + "대/㏊", + "0.0084", + "손료계수", + "", + "610,000", + "예취기(대)", + "", + "15,884", + "", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "1,301,741", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,259,652", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "26,205", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "15,884", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "묘목찾기", + "모두베기", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 집단화 정도(1-4-3)", + "개소당 평균면적이 1~3ha 미만", + "5%", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "중경사(15∼30°)", + "5%", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "10%", + "10%", + "", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-01", + "number": "2-2-1", + "name": "체인톱", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 17408, + "tables": [ + { + "pum_table_id": "F0064", + "section": "2-2-1. 체인톱", + "source_line": 1598, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "45cc (배기량기준)" + ], + "condition_note": [ + "규격", + "손료계수 (1일 1대당)", + "적용기준" + ], + "raw_row": [ + [ + "45cc (배기량기준)", + "0.0084", + "∙ 체인톱 수량(대) = 산출된 벌목부(체인톱 사용) 인원(인)×100% ∙ 기계손료 = 체인톱수량×손료계수×체인톱가격" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-02", + "number": "2-2-2", + "name": "예취기", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 17664, + "tables": [ + { + "pum_table_id": "F0065", + "section": "2-2-2. 예취기", + "source_line": 1611, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "예취기 (배기량 35cc)" + ], + "condition_note": [ + "기계명 (규격)", + "손료계수 (1일 1대당)", + "적용기준" + ], + "raw_row": [ + [ + "예취기 (배기량 35cc)", + "0.0084", + "∙ 1대당 1인 작업 ∙ 기계손료 = 손료계수 ×예취기가격×인원" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-03", + "number": "2-2-3", + "name": "집재장비", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 17920, + "tables": [ + { + "pum_table_id": "F0066", + "section": "2-2-3. 집재장비", + "source_line": 1622, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "아키아윈치", + "2드럼 케이블윈치", + "파르미윈치(트랙터 포함)", + "타이푼윈치(트랙터 포함)", + "우드피싱(굴삭기 포함)", + "무한궤도형 임내차", + "스마트집재기(트랙터 포함)", + "HAM300(트랙터 포함)", + "콜라타워야더(K-301)", + "소형굴삭기+부착용집개", + "임업용 동력집재기(트랙터부착형)", + "HAM200, 춘천집재기, 스마트집재기", + "하베스터(헤드부착형)", + "0.6㎥급 무한궤도형 굴착기", + "동력상하차기, 임업용 굴착기(우드그래플)", + "타워야더(RME 300T)", + "스윙야더(기본차량 포함)", + "초소형 포워더", + "소형 포워더", + "소형트럭" + ], + "condition_note": [ + "기계명", + "손료계수 (1일 1대당)", + "장비가격 (천원)", + "적용기준" + ], + "raw_row": [ + [ + "아키아윈치", + "0.0028", + "8,000", + "손료계수×장비 가격×적용일수" + ], + [ + "2드럼 케이블윈치", + "0.0012", + "12,000", + "" + ], + [ + "파르미윈치(트랙터 포함)", + "0.0008", + "90,000", + "" + ], + [ + "타이푼윈치(트랙터 포함)", + "0.0008", + "81,000", + "" + ], + [ + "우드피싱(굴삭기 포함)", + "0.0015", + "72,000", + "" + ], + [ + "무한궤도형 임내차", + "0.0008", + "135,000", + "" + ], + [ + "스마트집재기(트랙터 포함)", + "0.0008", + "94,000", + "" + ], + [ + "HAM300(트랙터 포함)", + "0.0008", + "160,000", + "" + ], + [ + "콜라타워야더(K-301)", + "0.0008", + "300,000", + "" + ], + [ + "소형굴삭기+부착용집개", + "0.0015", + "65,000", + "" + ], + [ + "임업용 동력집재기(트랙터부착형)", + "0.0008", + "55,000", + "" + ], + [ + "HAM200, 춘천집재기, 스마트집재기", + "0.0008", + "60,000", + "" + ], + [ + "하베스터(헤드부착형)", + "0.0009", + "85,000", + "" + ], + [ + "0.6㎥급 무한궤도형 굴착기", + "0.0017", + "97,000", + "" + ], + [ + "동력상하차기, 임업용 굴착기(우드그래플)", + "0.0015", + "60,000", + "" + ], + [ + "타워야더(RME 300T)", + "0.0008", + "180,000", + "" + ], + [ + "스윙야더(기본차량 포함)", + "0.0010", + "160,000", + "" + ], + [ + "초소형 포워더", + "0.0008", + "85,000", + "" + ], + [ + "소형 포워더", + "0.0008", + "150,000", + "" + ], + [ + "소형트럭", + "0.0027", + "20,000", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-04", + "number": "2-2-4", + "name": "배부식분무기", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 18176, + "tables": [ + { + "pum_table_id": "F0067", + "section": "2-2-4. 배부식분무기(덩굴 약제처리, ha당)", + "source_line": 1651, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "배부식분무기" + ], + "condition_note": [ + "기계명 (규격)", + "손료계수 (1일 1대당)", + "적용기준" + ], + "raw_row": [ + [ + "배부식분무기", + "0.0084", + "∙ 1대당 1인 작업 ∙ 기계손료 = 손료계수 ×분무기가격×인원" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-05", + "number": "2-2-5", + "name": "천공기", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 18432, + "tables": [ + { + "pum_table_id": "F0068", + "section": "2-2-5. 천공기", + "source_line": 1661, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": 1.0, + "basis_unit": "인", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "45cc (배기량 기준)" + ], + "condition_note": [ + "규 격", + "손료계수", + "적용기준" + ], + "raw_row": [ + [ + "45cc (배기량 기준)", + "0.0084", + "∙ 천공인부 1인당 1대 적용 (계산식 : 손료계수×천공기가격×인원)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-06", + "number": "2-2-6", + "name": "양수기", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 18688, + "tables": [ + { + "pum_table_id": "F0069", + "section": "2-2-6. 양수기(유인헬기 방제용)", + "source_line": 1670, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "5HP(양수기)" + ], + "condition_note": [ + "규 격", + "손료계수(1일 1대당)", + "비 고" + ], + "raw_row": [ + [ + "5HP(양수기)", + "0.0084", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-07", + "number": "2-2-7", + "name": "드론 무인 헬리콥터", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 18944, + "tables": [ + { + "pum_table_id": "F0070", + "section": "2-2-7. 드론 무인 헬리콥터", + "source_line": 1676, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "무인헬기(FAZER) 내용가동시간 1,400시간기준" + ], + "condition_note": [ + "규 격", + "1일 손료계수 (1일 1대당 4시간 가동)", + "비 고" + ], + "raw_row": [ + [ + "무인헬기(FAZER) 내용가동시간 1,400시간기준", + "0.00174", + "취득가액 금 200,000,000원" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-08", + "number": "2-2-8", + "name": "드론 무인 멀티콥터", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 19200, + "tables": [ + { + "pum_table_id": "F0071", + "section": "2-2-8. 드론 무인 멀티콥터", + "source_line": 1687, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "무인멀티콥터(평균가) 내용가동시간 1,000시간 기준", + "리튬폴리머 배터리 (평균가) (16,000mA, 32000mA)" + ], + "condition_note": [ + "규 격", + "1일 손료계수 (1일 1대당 4시간 가동)", + "비 고" + ], + "raw_row": [ + [ + "무인멀티콥터(평균가) 내용가동시간 1,000시간 기준", + "0.0040", + "취득가액 9,894,500원" + ], + [ + "리튬폴리머 배터리 (평균가) (16,000mA, 32000mA)", + "0.0028", + "취득가액 7,104,000원 (10조)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-09", + "number": "2-2-9", + "name": "지상 약제살포", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 19456, + "tables": [ + { + "pum_table_id": "F0072", + "section": "2-2-9. 지상 약제살포", + "source_line": 1700, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "1톤(방제차량)", + "45HP(동력분무기)" + ], + "condition_note": [ + "규 격", + "손료계수 (1일 1대당)", + "비 고" + ], + "raw_row": [ + [ + "1톤(방제차량)", + "건설품셈 적산기준에 준함", + "" + ], + [ + "45HP(동력분무기)", + "0.0084", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-10", + "number": "2-2-10", + "name": "파쇄", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 19712, + "tables": [ + { + "pum_table_id": "F0073", + "section": "2-2-10. 파쇄", + "source_line": 1709, + "pum_form": "coefficient", + "form_basis": "헤더 '손료계수'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "-" + ], + "condition_note": [ + "규격", + "장비가격(천원)", + "시간당손료계수(x 10)", + "적용내역" + ], + "raw_row": [ + [ + "-", + "-", + "3,349", + "1일 1대당 3인1조 작업 (운전원 1인, 보통인부 2인)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-02-02-11", + "number": "2-2-11", + "name": "기타장비", + "level": 3, + "parent_code": "FP-02-02", + "sort_order": 19968, + "tables": [ + { + "pum_table_id": "F0074", + "section": "2-2-11. 기타장비", + "source_line": 1717, + "pum_form": "coefficient", + "form_basis": "헤더 '기계손료'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "손료계수(10⁻⁷)", + "굴착기(0.2~0.8㎥)", + "부착용집게(0.2~0.8㎥)", + "트럭탑재형크레인", + "타이어형크레인" + ], + "condition_note": [ + "기 계 명", + "기계손료(시간당 적용기준)" + ], + "raw_row": [ + [ + "손료계수(10⁻⁷)", + "적용기준" + ], + [ + "굴착기(0.2~0.8㎥)", + "「건설공사 표준품셈 공통부문, 8-3-1 [00]토공기계,(0201)굴착기(무한궤도), (0211)굴착기(타이어)(2025)」적용" + ], + [ + "부착용집게(0.2~0.8㎥)", + "「건설공사 표준품셈 공통부문, 8-3-8 [70]기타기계, (7206)부착용 집게(2025)」 적용" + ], + [ + "트럭탑재형크레인", + "「건설공사 표준품셈 공통부문, 8-3-3 [20]운반 및 하역기계, (2105)트랙터탑재형 크레인(2025)」 적용" + ], + [ + "타이어형크레인", + "「건설공사 표준품셈 공통부문, 8-3-3 [20]운반 및 하역기계, (2104)크레인(타이어)(2025)」 적용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03", + "number": "3", + "name": "작업장 관리", + "level": 1, + "parent_code": null, + "sort_order": 20224, + "tables": [] + }, + { + "work_item_code": "FP-03-01", + "number": "3-1", + "name": "경계표시", + "level": 2, + "parent_code": "FP-03", + "sort_order": 20480, + "tables": [ + { + "pum_table_id": "F0075", + "section": "3-1. 경계표시", + "source_line": 1731, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소요인력", + "0.2" + ], + "condition_note": [ + "(단위 : 인/ha)" + ], + "raw_row": [ + [ + "소요인력", + "인력구분" + ], + [ + "0.2", + "보통인부" + ] + ] + }, + { + "pum_table_id": "F0472", + "section": "3-1. 임업용 동력기계톱, 우드그래플, 소형트럭", + "source_line": 8753, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 면적", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ단목", + "- 재료비(체인톱)", + "ㆍ보통휘발유(주연료)", + "ㆍ보통휘발유(잡품)", + "ㆍ체인오일(일반)", + "- 기계경비(체인톱)", + "2. 집재", + "ㆍ우드그래플", + "1.2m", + "1.8m", + "2.1m", + "2.7m", + "3.6m", + "- 재료비(우드그래플)", + "ㆍ경유(주연료)", + "ㆍ경유(잡품)", + "- 기계경비(우드그래플)", + "3. 운재", + "ㆍ소형트럭", + "- 재료비(소형트럭)" + ], + "condition_note": [ + "ha당 임목수확 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 수확베기", + "", + "", + "", + "", + "", + "", + "", + "", + "3,690,865", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "3,376,217", + "", + "" + ], + [ + "ㆍ단목", + "260.84", + "㎥/ha", + "20.17", + "㎥/인", + "12.93", + "248,681", + "벌목부", + "5%", + "3,376,217", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "216,898", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "12.93", + "대/ha", + "5.60", + "ℓ/대", + "72.41", + "1,537", + "무연(ℓ)", + "", + "111,294", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "111,294", + "원", + "40", + "%", + "", + "", + "", + "", + "44,517", + "", + "" + ], + [ + "ㆍ체인오일(일반)", + "12.93", + "대/ha", + "2.10", + "ℓ/대", + "27.15", + "2,250", + "국내산(ℓ)", + "", + "61,087", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "12.93", + "대/ha", + "0.0084", + "", + "", + "900,000", + "체인톱(대)", + "", + "97,750", + "", + "" + ], + [ + "2. 집재", + "", + "", + "", + "", + "", + "", + "", + "", + "2,577,116", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,715,058", + "", + "" + ], + [ + "ㆍ우드그래플", + "203.98", + "㎥/ha", + "", + "", + "6.26", + "273,971", + "건설기계운전사", + "0%", + "1,715,058", + "", + "" + ], + [ + "1.2m", + "", + "㎥/ha", + "18.16", + "㎥/조", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "1.8m", + "", + "㎥/ha", + "25.34", + "㎥/조", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "2.1m", + "101.99", + "㎥/ha", + "28.56", + "㎥/조", + "3.57", + "", + "", + "", + "", + "50%", + "" + ], + [ + "2.7m", + "", + "㎥/ha", + "34.40", + "㎥/조", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "3.6m", + "101.99", + "㎥/ha", + "37.96", + "㎥/조", + "2.69", + "", + "", + "", + "", + "50%", + "" + ], + [ + "- 재료비(우드그래플)", + "", + "", + "", + "", + "", + "", + "", + "", + "251,708", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "6.26", + "대/ha", + "20.80", + "ℓ/대", + "130.21", + "1,487", + "경유(ℓ)", + "", + "193,622", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "193,622", + "원", + "30", + "%", + "", + "", + "", + "", + "58,086", + "", + "" + ], + [ + "- 기계경비(우드그래플)", + "6.26", + "대/ha", + "0.0015", + "손료계수", + "", + "65,000,000", + "우드그래플(대)", + "", + "610,350", + "", + "" + ], + [ + "3. 운재", + "", + "", + "", + "", + "", + "", + "", + "", + "2,273,881", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,745,195", + "", + "" + ], + [ + "ㆍ소형트럭", + "203.98", + "㎥/ha", + "32.00", + "㎥/대", + "6.37", + "273,971", + "건설기계운전사", + "0%", + "1,745,195", + "500m이하", + "" + ], + [ + "- 재료비(소형트럭)", + "", + "", + "", + "", + "", + "", + "", + "", + "184,706", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "6.37", + "대/ha", + "15.00", + "ℓ/대", + "95.55", + "1,487", + "경유(ℓ)", + "", + "142,082", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "142,082", + "원", + "30", + "%", + "", + "", + "", + "", + "42,624", + "", + "" + ], + [ + "- 기계경비(소형트럭)", + "6.37", + "대/ha", + "0.0027", + "손료계수", + "", + "20,000,000", + "소형트럭(대)", + "", + "343,980", + "", + "" + ], + [ + "", + "4. 집적", + "", + "", + "", + "", + "", + "", + "", + "", + "3,430,332", + "" + ], + [ + "4-1. 집적작업", + "", + "", + "", + "", + "", + "", + "", + "", + "2,062,517", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,372,594", + "", + "" + ], + [ + "ㆍ우드그래플", + "203.98", + "㎥/ha", + "", + "", + "5.01", + "273,971", + "건설기계운전사", + "", + "1,372,594", + "원목평균 직경24cm", + "" + ], + [ + "1.8m", + "", + "㎥/ha", + "34.48", + "인/㎥", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "2.1m", + "101.99", + "㎥/ha", + "36.89", + "인/㎥", + "2.76", + "", + "", + "", + "", + "", + "" + ], + [ + "2.7m", + "", + "㎥/ha", + "39.94", + "인/㎥", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "3.6m", + "101.99", + "㎥/ha", + "45.41", + "인/㎥", + "2.25", + "", + "", + "", + "", + "", + "" + ], + [ + "- 재료비(우드그래플)", + "", + "", + "", + "", + "", + "", + "", + "", + "201,448", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "5.01", + "대/ha", + "20.80", + "ℓ/대", + "104.21", + "1,487", + "경유(ℓ)", + "", + "154,960", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "154,960", + "원", + "30", + "%", + "", + "", + "", + "", + "46,488", + "", + "" + ], + [ + "- 기계경비(우드그래플)", + "5.01", + "대/ha", + "0.0015", + "손료계수", + "", + "65,000,000", + "우드그래플(대)", + "", + "488,475", + "", + "" + ], + [ + "4-2. 토막내기", + "", + "", + "", + "", + "", + "", + "", + "", + "1,367,815", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,245,891", + "", + "" + ], + [ + "ㆍ체인톱", + "203.98", + "㎥/ha", + "집적품과 동일 적용", + "5.01", + "248,681", + "벌목부", + "", + "1,245,891", + "", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "84,049", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "5.01", + "대/ha", + "5.60", + "ℓ/대", + "28.06", + "1,537", + "무연(ℓ)", + "", + "43,128", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "43,128", + "원", + "40", + "%", + "", + "", + "", + "", + "17,251", + "", + "" + ], + [ + "ㆍ체인오일(일반)", + "5.01", + "대/ha", + "2.10", + "ℓ/대", + "10.52", + "2,250", + "국내산(ℓ)", + "", + "23,670", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "5.01", + "대/ha", + "0.0084", + "", + "", + "900,000", + "체인톱(대)", + "", + "37,875", + "", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "11,972,194", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "9,454,955", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "938,809", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "1,578,430", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "수확베기", + "우드그래플집재", + "소형트럭운재", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "완(15°미만)", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 장애물의 정도(1-4-8)", + "무릎높이 이하의 초본․관목", + "0%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 주행 장애물 상태(1-4-13)", + "주행에 어려움이 없다", + "", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 벌도목평균경급", + "20~30cm", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 잔존목 본수", + "70본 이하", + "", + "0%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "5%", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-02", + "number": "3-2", + "name": "작업로 선정", + "level": 2, + "parent_code": "FP-03", + "sort_order": 20736, + "tables": [ + { + "pum_table_id": "F0076", + "section": "3-2. 작업로 선정", + "source_line": 1742, + "pum_form": "productivity", + "form_basis": "헤더 '인/1일'", + "basis_quantity": 1.0, + "basis_unit": "km", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "구 분", + "작업로 예정선 선정 및 표식" + ], + "condition_note": [ + "(단위 : 인/1일 1km당))" + ], + "raw_row": [ + [ + "구 분", + "소요인력", + "인력구성" + ], + [ + "작업로 예정선 선정 및 표식", + "1.0", + "초급기술자" + ] + ] + }, + { + "pum_table_id": "F0473", + "section": "3-2. 임업용 동력기계톱, 스마트집재기, 우드그래플, 초소형포워더", + "source_line": 8828, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 면적", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ전간", + "- 재료비(체인톱)", + "ㆍ보통휘발유(주연료)", + "ㆍ보통휘발유(잡품)", + "ㆍ체인오일(일반)", + "- 기계경비(체인톱)", + "2. 집재", + "2-1. 스마트집재기", + "ㆍ스마트집재기", + "50m이하", + "51~100m", + "101~15m", + "- 재료비(집재기)", + "ㆍ경유(주연료)", + "ㆍ경유(잡품)", + "- 기계경비(집재기)", + "2-2. 임업용굴착기", + "ㆍ우드그래플", + "- 재료비(우드그래플)", + "- 기계경비(우드그래플)" + ], + "condition_note": [ + "ha당 임목수확 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 수확베기", + "", + "", + "", + "", + "", + "", + "", + "", + "3,174,550", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "2,925,607", + "", + "" + ], + [ + "ㆍ전간", + "260.84", + "㎥/ha", + "25.50", + "㎥/인", + "10.23", + "248,681", + "벌목부", + "15%", + "2,925,607", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "171,605", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "10.23", + "대/ha", + "5.60", + "ℓ/대", + "57.29", + "1,537", + "무연(ℓ)", + "", + "88,054", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "88,054", + "원", + "40", + "%", + "", + "", + "", + "", + "35,221", + "", + "" + ], + [ + "ㆍ체인오일(일반)", + "10.23", + "대/ha", + "2.10", + "ℓ/대", + "21.48", + "2,250", + "국내산(ℓ)", + "", + "48,330", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "10.23", + "대/ha", + "0.0084", + "", + "", + "900,000", + "체인톱(대)", + "", + "77,338", + "", + "" + ], + [ + "2. 집재", + "", + "", + "", + "", + "", + "", + "", + "", + "7,826,747", + "", + "" + ], + [ + "2-1. 스마트집재기", + "", + "", + "", + "", + "", + "", + "", + "", + "5,307,262", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "4,682,247", + "", + "" + ], + [ + "ㆍ스마트집재기", + "203.98", + "㎥/ha", + "", + "", + "6.12", + "665,281", + "건설+특별+보통", + "15%", + "4,682,247", + "3인1조", + "" + ], + [ + "50m이하", + "67.24", + "㎥/ha", + "45.83", + "㎥/조", + "1.47", + "(273,971)", + "(건설기계운전사 1명)", + "", + "", + "33%", + "" + ], + [ + "51~100m", + "91.90", + "㎥/ha", + "32.26", + "㎥/조", + "2.85", + "(221,506)", + "(특별인부 1명)", + "", + "", + "45%", + "" + ], + [ + "101~15m", + "44.84", + "㎥/ha", + "24.89", + "㎥/조", + "1.80", + "(169,804)", + "(보통인부 1명)", + "", + "", + "22%", + "" + ], + [ + "- 재료비(집재기)", + "", + "", + "", + "", + "", + "", + "", + "", + "331,255", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "6.12", + "대/ha", + "26.00", + "ℓ/대", + "159.12", + "1,487", + "경유(ℓ)", + "", + "236,611", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "236,611", + "원", + "40", + "%", + "", + "", + "", + "", + "94,644", + "", + "" + ], + [ + "- 기계경비(집재기)", + "6.12", + "대/ha", + "0.0008", + "손료계수", + "", + "60,000,000", + "우드그래플(대)", + "", + "293,760", + "", + "" + ], + [ + "2-2. 임업용굴착기", + "", + "", + "", + "", + "", + "", + "", + "", + "2,519,485", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,676,702", + "", + "" + ], + [ + "ㆍ우드그래플", + "203.98", + "㎥/ha", + "집재품과 동일 적용", + "6.12", + "273,971", + "건설기계운전사", + "0%", + "1,676,702", + "", + "", + "" + ], + [ + "- 재료비(우드그래플)", + "", + "", + "", + "", + "", + "", + "", + "", + "246,083", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "6.12", + "대/ha", + "20.80", + "ℓ/대", + "127.30", + "1,487", + "경유(ℓ)", + "", + "189,295", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "189,295", + "원", + "30", + "%", + "", + "", + "", + "", + "56,788", + "", + "" + ], + [ + "- 기계경비(우드그래플)", + "6.12", + "대/ha", + "0.0015", + "손료계수", + "", + "65,000,000", + "우드그래플(대)", + "", + "596,700", + "", + "" + ], + [ + "3. 운재", + "", + "", + "", + "", + "", + "", + "", + "", + "2,871,133", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "2,005,467", + "", + "" + ], + [ + "ㆍ초소형포워더", + "203.98", + "㎥/ha", + "27.86", + "㎥/대", + "7.32", + "273,971", + "건설기계운전사", + "0%", + "2,005,467", + "500m이하", + "" + ], + [ + "- 재료비(초소형포워더)", + "", + "", + "", + "", + "", + "", + "", + "", + "367,906", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "7.32", + "대/ha", + "26.00", + "ℓ/대", + "190.32", + "1,487", + "경유(ℓ)", + "", + "283,005", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "283,005", + "원", + "30", + "%", + "", + "", + "", + "", + "84,901", + "", + "" + ], + [ + "- 기계경비(초소형포워더)", + "7.32", + "대/ha", + "0.0008", + "손료계수", + "", + "85,000,000", + "초소형포워더(대)", + "", + "497,760", + "", + "" + ], + [ + "4. 집적", + "", + "", + "", + "", + "", + "", + "", + "", + "3,430,332", + "", + "" + ], + [ + "4-1. 집적작업", + "", + "", + "", + "", + "", + "", + "", + "", + "2,062,517", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,372,594", + "", + "" + ], + [ + "ㆍ우드그래플", + "203.98", + "㎥/ha", + "", + "", + "5.01", + "273,971", + "건설기계운전사", + "", + "1,372,594", + "원목평균 직경24cm", + "" + ], + [ + "1.8m", + "", + "㎥/ha", + "34.48", + "인/㎥", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "2.1m", + "101.99", + "㎥/ha", + "36.89", + "인/㎥", + "2.76", + "", + "", + "", + "", + "", + "" + ], + [ + "2.7m", + "", + "㎥/ha", + "39.94", + "인/㎥", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "3.6m", + "101.99", + "㎥/ha", + "45.41", + "인/㎥", + "2.25", + "", + "", + "", + "", + "", + "" + ], + [ + "- 재료비(우드그래플)", + "", + "", + "", + "", + "", + "", + "", + "", + "201,448", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "5.01", + "대/ha", + "20.80", + "ℓ/대", + "104.21", + "1,487", + "경유(ℓ)", + "", + "154,960", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "154,960", + "원", + "30", + "%", + "", + "", + "", + "", + "46,488", + "", + "" + ], + [ + "- 기계경비(우드그래플)", + "5.01", + "대/ha", + "0.0015", + "손료계수", + "", + "65,000,000", + "우드그래플(대)", + "", + "488,475", + "", + "" + ], + [ + "4-2. 토막내기", + "", + "", + "", + "", + "", + "", + "", + "", + "1,367,815", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,245,891", + "", + "" + ], + [ + "ㆍ체인톱", + "203.98", + "㎥/ha", + "집적품과 동일품", + "5.01", + "248,681", + "벌목부", + "", + "1,245,891", + "", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "84,049", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "5.01", + "대/ha", + "5.60", + "ℓ/대", + "28.06", + "1,537", + "무연(ℓ)", + "", + "43,128", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "43,128", + "원", + "40", + "%", + "", + "", + "", + "", + "17,251", + "", + "" + ], + [ + "ㆍ체인오일(일반)", + "5.01", + "대/ha", + "2.10", + "ℓ/대", + "10.52", + "2,250", + "국내산(ℓ)", + "", + "23,670", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "5.01", + "대/ha", + "0.0084", + "", + "", + "900,000", + "체인톱(대)", + "", + "37,875", + "", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "17,302,762", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "13,908,508", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,402,346", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "1,991,908", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "수확베기", + "스마트집재기", + "초소형 포워더 운재", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "중(15~30°미만)", + "5%", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 장애물의 정도(1-4-8)", + "가슴높이 미만의 초본․관목", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 주행 장애물 상태(1-4-13)", + "주행에 어려움이 없다", + "", + "", + "0%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 집재방향(1-4-14)", + "상향집재", + "", + "0%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 횡단집재거리(1-4-15)", + "11~20m", + "", + "10%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 벌도목평균경급", + "20~30cm", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 잔존목 본수", + "70본 이하", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "15%", + "15%", + "0%", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-03", + "number": "3-3", + "name": "작업로 설치", + "level": 2, + "parent_code": "FP-03", + "sort_order": 20992, + "tables": [ + { + "pum_table_id": "F0077", + "section": "3-3. 작업로 설치", + "source_line": 1754, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "구 분", + "소작업로", + "대작업로" + ], + "condition_note": [ + "(단위 : 인/km당)" + ], + "raw_row": [ + [ + "구 분", + "소요인력", + "인력구성" + ], + [ + "소작업로", + "2.0", + "벌목부 50% 보통인부 50%" + ], + [ + "대작업로", + "3.0", + "" + ] + ] + }, + { + "pum_table_id": "F0474", + "section": "3-3. 하베스터, 타워야더, 우드그래플, 소형포워더", + "source_line": 8912, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 면적", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ하베스터", + "- 재료비(하베스터)", + "ㆍ경유(주연료)", + "ㆍ경유(잡품)", + "- 기계경비", + "ㆍ하베스터헤드", + "ㆍ0.6㎥굴착기", + "2. 집재", + "2-1. 타워야더", + "ㆍ타워야더", + "100m이하", + "150m이하", + "- 재료비(집재기)", + "- 기계경비(집재기)", + "2-2. 임업용굴착기", + "ㆍ우드그래플", + "- 재료비(우드그래플)", + "- 기계경비(우드그래플)", + "3. 운재", + "ㆍ소형포워더" + ], + "condition_note": [ + "ha당 임목수확 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 수확베기", + "", + "", + "", + "", + "", + "", + "", + "", + "3,021,941", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,208,212", + "", + "" + ], + [ + "ㆍ하베스터", + "260.84", + "㎥/ha", + "59.20", + "㎥/인", + "4.41", + "273,971", + "건설기계운전사", + "0%", + "1,208,212", + "", + "" + ], + [ + "- 재료비(하베스터)", + "", + "", + "", + "", + "", + "", + "", + "", + "749,155", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "4.41", + "대/ha", + "81.60", + "ℓ/대", + "359.86", + "1,487", + "경유(ℓ)", + "", + "535,111", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "535,111", + "원", + "40", + "%", + "", + "", + "", + "", + "214,044", + "", + "" + ], + [ + "- 기계경비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,064,574", + "", + "" + ], + [ + "ㆍ하베스터헤드", + "4.41", + "대/ha", + "0.0009", + "", + "", + "85,000,000", + "하베스터(대)", + "", + "337,365", + "", + "" + ], + [ + "ㆍ0.6㎥굴착기", + "4.41", + "대/ha", + "0.0017", + "", + "", + "97,000,000", + "무한궤도형(대)", + "", + "727,209", + "", + "" + ], + [ + "2. 집재", + "", + "", + "", + "", + "", + "", + "", + "", + "8,988,007", + "", + "" + ], + [ + "2-1. 타워야더", + "", + "", + "", + "", + "", + "", + "", + "", + "6,736,114", + "RME300T", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "5,578,333", + "", + "" + ], + [ + "ㆍ타워야더", + "203.98", + "㎥/ha", + "", + "", + "5.47", + "886,787", + "건설+특별+보통", + "15%", + "5,578,333", + "4인1조", + "" + ], + [ + "100m이하", + "159.14", + "㎥/ha", + "38.11", + "㎥/조", + "4.18", + "(273,971)", + "(건설기계운전사 1명)", + "", + "", + "78%", + "" + ], + [ + "150m이하", + "44.84", + "㎥/ha", + "34.79", + "㎥/조", + "1.29", + "(208,527)", + "(특별인부 2명)", + "", + "", + "22%", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "(161,858)", + "(보통인부 1명)", + "", + "", + "", + "" + ], + [ + "- 재료비(집재기)", + "", + "", + "", + "", + "", + "", + "", + "", + "370,101", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "5.47", + "대/ha", + "32.50", + "ℓ/대", + "177.78", + "1,487", + "경유(ℓ)", + "", + "264,358", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "264,358", + "원", + "40", + "%", + "", + "", + "", + "", + "105,743", + "", + "" + ], + [ + "- 기계경비(집재기)", + "5.47", + "대/ha", + "0.0008", + "손료계수", + "", + "180,000,000", + "REM 300T", + "", + "787,680", + "", + "" + ], + [ + "2-2. 임업용굴착기", + "", + "", + "", + "", + "", + "", + "", + "", + "2,251,893", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,498,621", + "", + "" + ], + [ + "ㆍ우드그래플", + "203.98", + "㎥/ha", + "집재품과 동일 적용", + "5.47", + "273,971", + "건설기계운전사", + "0%", + "1,498,621", + "", + "", + "" + ], + [ + "- 재료비(우드그래플)", + "", + "", + "", + "", + "", + "", + "", + "", + "219,947", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "5.47", + "대/ha", + "20.80", + "ℓ/대", + "113.78", + "1,487", + "경유(ℓ)", + "", + "169,190", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "169,190", + "원", + "30", + "%", + "", + "", + "", + "", + "50,757", + "", + "" + ], + [ + "- 기계경비(우드그래플)", + "5.47", + "대/ha", + "0.0015", + "손료계수", + "", + "65,000,000", + "우드그래플(대)", + "", + "533,325", + "", + "" + ], + [ + "3. 운재", + "", + "", + "", + "", + "", + "", + "", + "", + "996,287", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "591,777", + "", + "" + ], + [ + "ㆍ소형포워더", + "203.98", + "㎥/ha", + "94.48", + "㎥/대", + "2.16", + "273,971", + "건설기계운전사", + "0%", + "591,777", + "500m이하", + "" + ], + [ + "- 재료비(소형포워더)", + "", + "", + "", + "", + "", + "", + "", + "", + "145,310", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "2.16", + "대/ha", + "34.80", + "ℓ/대", + "75.17", + "1,487", + "경유(ℓ)", + "", + "111,777", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "111,777", + "원", + "30", + "%", + "", + "", + "", + "", + "33,533", + "", + "" + ], + [ + "- 기계경비(소형포워더)", + "2.16", + "대/ha", + "0.0008", + "손료계수", + "", + "150,000,000", + "소형포워더(대)", + "", + "259,200", + "", + "" + ], + [ + "4. 집적", + "", + "", + "", + "", + "", + "", + "", + "", + "2,062,517", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,372,594", + "", + "" + ], + [ + "ㆍ우드그래플", + "203.98", + "㎥/ha", + "", + "", + "5.01", + "273,971", + "건설기계운전사", + "", + "1,372,594", + "직경24cm", + "" + ], + [ + "1.8m", + "", + "㎥/ha", + "34.48", + "인/㎥", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "2.1m", + "101.99", + "㎥/ha", + "36.89", + "인/㎥", + "2.76", + "", + "", + "", + "", + "", + "" + ], + [ + "2.7m", + "", + "㎥/ha", + "39.94", + "인/㎥", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "3.6m", + "101.99", + "㎥/ha", + "45.41", + "인/㎥", + "2.25", + "", + "", + "", + "", + "", + "" + ], + [ + "- 재료비(우드그래플)", + "", + "", + "", + "", + "", + "", + "", + "", + "201,448", + "", + "" + ], + [ + "ㆍ경유(주연료)", + "5.01", + "대/ha", + "20.80", + "ℓ/대", + "104.21", + "1,487", + "경유(ℓ)", + "", + "154,960", + "", + "" + ], + [ + "ㆍ경유(잡품)", + "154,960", + "원", + "30", + "%", + "", + "", + "", + "", + "46,488", + "", + "" + ], + [ + "- 기계경비(우드그래플)", + "5.01", + "대/ha", + "0.0015", + "손료계수", + "", + "65,000,000", + "우드그래플(대)", + "", + "488,475", + "", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "15,068,752", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "10,294,537", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,685,961", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "3,133,254", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "하베스터", + "타워야더", + "소형 포워더 운재", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "중(15~30°미만)", + "", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 장애물의 정도(1-4-8)", + "가슴높이 미만의 초본․관목", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 주행 장애물 상태(1-4-13)", + "주행에 어려움이 없다", + "", + "", + "0%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 집재방향(1-4-14)", + "상향집재", + "", + "0%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 횡단집재거리(1-4-15)", + "11~20m", + "", + "10%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 벌도목평균경급", + "20~30cm", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 잔존목 본수", + "70본 이하", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "0%", + "15%", + "0%", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-04", + "number": "3-4", + "name": "임산물 운반로 신설 및 보수․복구", + "level": 2, + "parent_code": "FP-03", + "sort_order": 21248, + "tables": [] + }, + { + "work_item_code": "FP-03-04-01", + "number": "3-4-1", + "name": "신설 및 보수 기준", + "level": 3, + "parent_code": "FP-03-04", + "sort_order": 21504, + "tables": [] + }, + { + "work_item_code": "FP-03-04-02", + "number": "3-4-2", + "name": "임산물 운반로 및 작업로 신설비 산정", + "level": 3, + "parent_code": "FP-03-04", + "sort_order": 21760, + "tables": [] + }, + { + "work_item_code": "FP-03-04-03", + "number": "3-4-3", + "name": "임산물 운반로 및 작업로 보수비 산정", + "level": 3, + "parent_code": "FP-03-04", + "sort_order": 22016, + "tables": [ + { + "pum_table_id": "F0078", + "section": "3-4-3. 임산물 운반로 및 작업로 보수비 산정", + "source_line": 1815, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "노면정지", + "노면굴기" + ], + "condition_note": [ + "종 별", + "인 부", + "비 고" + ], + "raw_row": [ + [ + "노면정지", + "1.5인", + "잡초 및 지름 5cm이상의 소석제거, 노면반출, 기타 노면을 고르게 하는 등의 노면복구" + ], + [ + "노면굴기", + "4.0인", + "노면두께 10cm내외의 괭이고르기, 다지기, 잡초 및 지름 5cm이상의 소석제거, 도로의 반출 등의 노면복구" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-05", + "number": "3-5", + "name": "예정지정리", + "level": 2, + "parent_code": "FP-03", + "sort_order": 22272, + "tables": [ + { + "pum_table_id": "F0079", + "section": "3-5. 예정지정리", + "source_line": 1824, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "모든 벌채산물 임내존치지역", + "휴경지 등 정리", + "관목지 등 정리", + "산불 등 피해지 정리", + "불량림 정리", + "벌채부산물 임내존치지역", + "임업기계장비 이용 정리", + "벌채와 동시정리지역", + "모든 벌채부산물 반출" + ], + "condition_note": [ + "구 분", + "작업내용", + "소요인력 (인/ha)", + "인력구분" + ], + "raw_row": [ + [ + "모든 벌채산물 임내존치지역", + "보완사업지 정리", + "2.4", + "특별인부 50% 보통인부 50%" + ], + [ + "휴경지 등 정리", + "5.5", + "", + "" + ], + [ + "관목지 등 정리", + "6.0", + "", + "" + ], + [ + "산불 등 피해지 정리", + "10.0", + "", + "" + ], + [ + "불량림 정리", + "10.0", + "", + "" + ], + [ + "벌채부산물 임내존치지역", + "인력 정리", + "10.0", + "" + ], + [ + "임업기계장비 이용 정리", + "9.0", + "", + "" + ], + [ + "벌채와 동시정리지역", + "벌채부산물 임내정리", + "7.0", + "" + ], + [ + "모든 벌채부산물 반출", + "9.0", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-06", + "number": "3-6", + "name": "산물 임내정리", + "level": 2, + "parent_code": "FP-03", + "sort_order": 22528, + "tables": [ + { + "pum_table_id": "F0080", + "section": "3-6. 산물 임내정리", + "source_line": 1867, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "10㎥ 미만", + "임내 정리" + ], + "condition_note": [ + "구 분", + "정리산물(㎥/ha)", + "비고" + ], + "raw_row": [ + [ + "10㎥ 미만", + "10㎥ 이상", + "15㎥ 이상", + "20㎥ 이상", + "25㎥ 이상", + "30㎥ 이상", + "35㎥ 이상", + "40㎥ 이상", + "45㎥ 이상", + "50㎥이상", + "", + "" + ], + [ + "임내 정리", + "3.9", + "4.1", + "4.3", + "4.5", + "4.6", + "4.7", + "4.8", + "4.9", + "5.0", + "5.1", + "보통 인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-07", + "number": "3-7", + "name": "재해산물 수집", + "level": 2, + "parent_code": "FP-03", + "sort_order": 22784, + "tables": [ + { + "pum_table_id": "F0081", + "section": "3-7. 재해산물 수집", + "source_line": 1887, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "10㎥ 미만", + "10m이하", + "20m이하", + "30m이하" + ], + "condition_note": [ + "수 집 거 리", + "재해산물 수집량(㎥/ha)", + "적용인부" + ], + "raw_row": [ + [ + "10㎥ 미만", + "10㎥ 이상", + "15㎥ 이상", + "20㎥ 이상", + "25㎥ 이상", + "30㎥ 이상", + "35㎥ 이상", + "40㎥ 이상", + "45㎥ 이상", + "50㎥ 이상", + "", + "" + ], + [ + "10m이하", + "3.9", + "4.1", + "5.2", + "6.3", + "7.4", + "8.6", + "9.8", + "11.0", + "12.3", + "13.7", + "보통인부" + ], + [ + "20m이하", + "2.8", + "3.0", + "4.1", + "5.2", + "6.3", + "7.3", + "8.5", + "9.7", + "11.0", + "12.3", + "" + ], + [ + "30m이하", + "1.9", + "2.2", + "3.3", + "4.4", + "5.5", + "6.5", + "7.6", + "8.7", + "9.9", + "11.1", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-03-08", + "number": "3-8", + "name": "드론 영상 촬영", + "level": 2, + "parent_code": "FP-03", + "sort_order": 23040, + "tables": [ + { + "pum_table_id": "F0082", + "section": "3-8. 드론 영상 촬영", + "source_line": 1901, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "드론조종자 (건설기계조종원)", + "계", + "① 촬영 계획 수립", + "② 드론 촬영(RTK 포함)", + "③ 영상 처리 및 정사영상 생성", + "④ 결과물 점검 및 보고서 작성" + ], + "condition_note": [ + "종 별", + "소요인력(인)" + ], + "raw_row": [ + [ + "드론조종자 (건설기계조종원)", + "부조종자 (건설기계조종원)", + "특별인부 (신호수)", + "" + ], + [ + "계", + "0.015", + "0.010", + "0.015" + ], + [ + "① 촬영 계획 수립", + "0.005", + "-", + "-" + ], + [ + "② 드론 촬영(RTK 포함)", + "0.010", + "0.010", + "-" + ], + [ + "③ 영상 처리 및 정사영상 생성", + "-", + "-", + "0.010" + ], + [ + "④ 결과물 점검 및 보고서 작성", + "-", + "-", + "0.005" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04", + "number": "4", + "name": "나무베기", + "level": 1, + "parent_code": null, + "sort_order": 23296, + "tables": [] + }, + { + "work_item_code": "FP-04-01", + "number": "4-1", + "name": "수확베기", + "level": 2, + "parent_code": "FP-04", + "sort_order": 23552, + "tables": [ + { + "pum_table_id": "F0475", + "section": "4-1. 소나무재선충병방제", + "source_line": 8989, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 임․소반", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ경급별(cm)", + "6", + "8", + "10", + "12", + "14", + "16", + "18", + "20", + "22", + "24", + "26", + "28", + "30", + "32", + "34", + "36", + "38", + "40", + "42" + ], + "condition_note": [ + "소나무재선충병방제 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 임․소반", + "1-0-1-0 또는 00임반 00소반", + "비고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원, 수량, 요율)", + "소요품", + "단가", + "할인․ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 벌목조재", + "", + "", + "", + "", + "", + "", + "53,093,885", + "", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "49,562,389", + "", + "" + ], + [ + "ㆍ경급별(cm)", + "608.55", + "㎥", + "", + "", + "290.24", + "209,242", + "벌목50% 보통50%", + "-20%", + "48,584,318", + "", + "" + ], + [ + "6", + "-", + "㎥", + "1.13", + "㎥/인", + "", + "(248,681)", + "(벌목부)", + "", + "", + "", + "" + ], + [ + "8", + "0.02", + "㎥", + "1.18", + "㎥/인", + "0.02", + "(169,804)", + "(보통인부)", + "", + "", + "", + "" + ], + [ + "10", + "0.12", + "㎥", + "1.24", + "㎥/인", + "0.10", + "", + "", + "", + "", + "", + "" + ], + [ + "12", + "1.05", + "㎥", + "1.26", + "㎥/인", + "0.83", + "", + "", + "", + "", + "", + "" + ], + [ + "14", + "3.59", + "㎥", + "1.29", + "㎥/인", + "2.78", + "", + "", + "", + "", + "", + "" + ], + [ + "16", + "7.40", + "㎥", + "1.32", + "㎥/인", + "5.61", + "", + "", + "", + "", + "", + "" + ], + [ + "18", + "14.21", + "㎥", + "1.34", + "㎥/인", + "10.60", + "", + "", + "", + "", + "", + "" + ], + [ + "20", + "21.09", + "㎥", + "1.51", + "㎥/인", + "13.97", + "", + "", + "", + "", + "", + "" + ], + [ + "22", + "22.57", + "㎥", + "1.60", + "㎥/인", + "14.11", + "", + "", + "", + "", + "", + "" + ], + [ + "24", + "33.48", + "㎥", + "1.74", + "㎥/인", + "19.24", + "", + "", + "", + "", + "", + "" + ], + [ + "26", + "27.60", + "㎥", + "1.84", + "㎥/인", + "15.00", + "", + "", + "", + "", + "", + "" + ], + [ + "28", + "42.54", + "㎥", + "1.94", + "㎥/인", + "21.93", + "", + "", + "", + "", + "", + "" + ], + [ + "30", + "54.57", + "㎥", + "2.05", + "㎥/인", + "26.62", + "", + "", + "", + "", + "", + "" + ], + [ + "32", + "54.71", + "㎥", + "2.13", + "㎥/인", + "25.68", + "", + "", + "", + "", + "", + "" + ], + [ + "34", + "44.07", + "㎥", + "2.21", + "㎥/인", + "19.94", + "", + "", + "", + "", + "", + "" + ], + [ + "36", + "73.76", + "㎥", + "2.29", + "㎥/인", + "32.21", + "", + "", + "", + "", + "", + "" + ], + [ + "38", + "53.72", + "㎥", + "2.35", + "㎥/인", + "22.86", + "", + "", + "", + "", + "", + "" + ], + [ + "40", + "35.14", + "㎥", + "2.48", + "㎥/인", + "14.17", + "", + "", + "", + "", + "", + "" + ], + [ + "42", + "36.84", + "㎥", + "2.55", + "㎥/인", + "14.45", + "", + "", + "", + "", + "", + "" + ], + [ + "44", + "18.91", + "㎥", + "2.62", + "㎥/인", + "7.22", + "", + "", + "", + "", + "", + "" + ], + [ + "46", + "13.21", + "㎥", + "2.68", + "㎥/인", + "4.93", + "", + "", + "", + "", + "", + "" + ], + [ + "48", + "17.52", + "㎥", + "2.74", + "㎥/인", + "6.39", + "", + "", + "", + "", + "", + "" + ], + [ + "50이상", + "32.43", + "㎥", + "2.80", + "㎥/인", + "11.58", + "", + "", + "", + "", + "", + "" + ], + [ + "ㆍ방제실행 등록", + "1,152", + "본", + "0.005", + "인/본", + "5.76", + "169,804", + "보통인부", + "", + "978,071", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "2,434,398", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "145.12", + "대", + "5.60", + "ℓ/대", + "812.67", + "1,537", + "무연(ℓ)", + "", + "1,249,073", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "1,249,073", + "원", + "40", + "%", + "", + "", + "", + "", + "499,629", + "", + "" + ], + [ + "ㆍ체인오일(일반오일)", + "145.12", + "대", + "2.10", + "ℓ/대", + "304.75", + "2,250", + "국내산(ℓ)", + "", + "685,687", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "145.12", + "대", + "0.0084", + "손료계수", + "", + "900,000", + "체인톱(대)", + "", + "1,097,107", + "", + "" + ], + [ + "2. 소운반", + "", + "", + "", + "", + "", + "", + "", + "", + "12,080,796", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "12,080,796", + "", + "" + ], + [ + "ㆍ경급별(cm)", + "478.13", + "㎥", + "", + "", + "72.17", + "209,242", + "벌목50% 보통50%", + "-20%", + "12,080,796", + "", + "" + ], + [ + "6", + "-", + "㎥", + "5.41", + "㎥/인", + "-", + "(248,681)", + "(벌목부)", + "", + "", + "", + "" + ], + [ + "8", + "-", + "㎥", + "5.50", + "㎥/인", + "-", + "(169,804)", + "(보통인부)", + "", + "", + "", + "" + ], + [ + "10", + "0.12", + "㎥", + "5.59", + "㎥/인", + "0.02", + "", + "", + "", + "", + "", + "" + ], + [ + "12", + "1.06", + "㎥", + "5.68", + "㎥/인", + "0.19", + "", + "", + "", + "", + "", + "" + ], + [ + "14", + "3.13", + "㎥", + "5.77", + "㎥/인", + "0.54", + "", + "", + "", + "", + "", + "" + ], + [ + "16", + "6.49", + "㎥", + "5.86", + "㎥/인", + "1.11", + "", + "", + "", + "", + "", + "" + ], + [ + "18", + "10.65", + "㎥", + "5.95", + "㎥/인", + "1.79", + "", + "", + "", + "", + "", + "" + ], + [ + "20", + "16.36", + "㎥", + "6.04", + "㎥/인", + "2.71", + "", + "", + "", + "", + "", + "" + ], + [ + "22", + "17.15", + "㎥", + "6.13", + "㎥/인", + "2.80", + "", + "", + "", + "", + "", + "" + ], + [ + "24", + "27.57", + "㎥", + "6.22", + "㎥/인", + "4.43", + "", + "", + "", + "", + "", + "" + ], + [ + "26", + "21.08", + "㎥", + "6.31", + "㎥/인", + "3.34", + "", + "", + "", + "", + "", + "" + ], + [ + "28", + "33.08", + "㎥", + "6.40", + "㎥/인", + "5.17", + "", + "", + "", + "", + "", + "" + ], + [ + "30", + "44.87", + "㎥", + "6.49", + "㎥/인", + "6.91", + "", + "", + "", + "", + "", + "" + ], + [ + "32", + "41.04", + "㎥", + "6.57", + "㎥/인", + "6.25", + "", + "", + "", + "", + "", + "" + ], + [ + "34", + "32.31", + "㎥", + "6.66", + "㎥/인", + "4.85", + "", + "", + "", + "", + "", + "" + ], + [ + "36", + "59.83", + "㎥", + "6.75", + "㎥/인", + "8.86", + "", + "", + "", + "", + "", + "" + ], + [ + "38", + "43.15", + "㎥", + "6.84", + "㎥/인", + "6.31", + "", + "", + "", + "", + "", + "" + ], + [ + "40", + "25.56", + "㎥", + "6.93", + "㎥/인", + "3.69", + "", + "", + "", + "", + "", + "" + ], + [ + "42", + "33.11", + "㎥", + "7.02", + "㎥/인", + "4.72", + "", + "", + "", + "", + "", + "" + ], + [ + "44", + "14.84", + "㎥", + "7.11", + "㎥/인", + "2.09", + "", + "", + "", + "", + "", + "" + ], + [ + "46", + "10.25", + "㎥", + "7.20", + "㎥/인", + "1.42", + "", + "", + "", + "", + "", + "" + ], + [ + "48", + "17.59", + "㎥", + "7.29", + "㎥/인", + "2.41", + "", + "", + "", + "", + "", + "" + ], + [ + "50이상", + "18.89", + "㎥", + "7.38", + "㎥/인", + "2.56", + "", + "", + "", + "", + "", + "" + ], + [ + "3. 무더기훈증", + "", + "", + "", + "", + "", + "", + "", + "", + "6,867,353", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "6,243,353", + "", + "" + ], + [ + "ㆍ경급별(cm)", + "865.40", + "RM", + "", + "RM/인", + "45.96", + "169,804", + "보통100%", + "-20%", + "6,243,353", + "", + "" + ], + [ + "6", + "-", + "RM", + "14.40", + "RM/인", + "-", + "", + "", + "", + "", + "", + "" + ], + [ + "8", + "-", + "RM", + "14.73", + "RM/인", + "-", + "", + "", + "", + "", + "", + "" + ], + [ + "10", + "0.22", + "RM", + "15.05", + "RM/인", + "0.01", + "", + "", + "", + "", + "", + "" + ], + [ + "12", + "1.92", + "RM", + "15.38", + "RM/인", + "0.12", + "", + "", + "", + "", + "", + "" + ], + [ + "14", + "5.67", + "RM", + "15.71", + "RM/인", + "0.36", + "", + "", + "", + "", + "", + "" + ], + [ + "16", + "11.75", + "RM", + "16.04", + "RM/인", + "0.73", + "", + "", + "", + "", + "", + "" + ], + [ + "18", + "19.28", + "RM", + "16.37", + "RM/인", + "1.18", + "", + "", + "", + "", + "", + "" + ], + [ + "20", + "29.61", + "RM", + "16.70", + "RM/인", + "1.77", + "", + "", + "", + "", + "", + "" + ], + [ + "22", + "31.04", + "RM", + "17.03", + "RM/인", + "1.82", + "", + "", + "", + "", + "", + "" + ], + [ + "24", + "49.90", + "RM", + "17.35", + "RM/인", + "2.88", + "", + "", + "", + "", + "", + "" + ], + [ + "26", + "38.15", + "RM", + "17.68", + "RM/인", + "2.16", + "", + "", + "", + "", + "", + "" + ], + [ + "28", + "59.87", + "RM", + "18.01", + "RM/인", + "3.32", + "", + "", + "", + "", + "", + "" + ], + [ + "30", + "81.21", + "RM", + "18.34", + "RM/인", + "4.43", + "", + "", + "", + "", + "", + "" + ], + [ + "32", + "74.28", + "RM", + "18.67", + "RM/인", + "3.98", + "", + "", + "", + "", + "", + "" + ], + [ + "34", + "58.48", + "RM", + "19.00", + "RM/인", + "3.08", + "", + "", + "", + "", + "", + "" + ], + [ + "36", + "108.29", + "RM", + "19.32", + "RM/인", + "5.61", + "", + "", + "", + "", + "", + "" + ], + [ + "38", + "78.10", + "RM", + "19.65", + "RM/인", + "3.97", + "", + "", + "", + "", + "", + "" + ], + [ + "40", + "46.26", + "RM", + "19.98", + "RM/인", + "2.32", + "", + "", + "", + "", + "", + "" + ], + [ + "42", + "59.93", + "RM", + "20.31", + "RM/인", + "2.95", + "", + "", + "", + "", + "", + "" + ], + [ + "44", + "26.86", + "RM", + "20.64", + "RM/인", + "1.30", + "", + "", + "", + "", + "", + "" + ], + [ + "46", + "18.55", + "RM", + "20.97", + "RM/인", + "0.88", + "", + "", + "", + "", + "", + "" + ], + [ + "48", + "31.84", + "RM", + "21.29", + "RM/인", + "1.50", + "", + "", + "", + "", + "", + "" + ], + [ + "50이상", + "34.19", + "RM", + "21.62", + "RM/인", + "1.58", + "", + "", + "", + "", + "", + "" + ], + [ + "- 재료비", + "", + "", + "", + "", + "", + "", + "", + "", + "624,000", + "", + "" + ], + [ + "ㆍ훈증 라벨(KW-H)", + "", + "", + "", + "", + "780", + "800", + "장당단가", + "", + "624,000", + "", + "" + ], + [ + "4. 잔가지줍기", + "", + "", + "", + "", + "", + "", + "", + "", + "3,129,487", + "", + "" + ], + [ + "- 직접노무비", + "1,152", + "본", + "0.016", + "본/인", + "18.43", + "169,804", + "보통100%", + "0%", + "3,129,487", + "", + "" + ], + [ + "합 계", + "순원가", + "", + "", + "", + "", + "", + "", + "", + "", + "75,171,521", + "" + ], + [ + "o 노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "71,016,025", + "", + "" + ], + [ + "o 재료비", + "", + "", + "", + "", + "", + "", + "", + "", + "3,058,389", + "", + "" + ], + [ + "o 경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "1,097,107", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "벌목조재", + "소운반", + "무더기훈증", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 장애물의 정도(1-4-8)", + "무릎높이 이하의 초본․관목", + "0%", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 방제대상목 분포(1-4-17)", + "45본/ha당", + "-20%", + "-20%", + "-20%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 방제수종(1-4-22)", + "소나무, 해송", + "0%", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "-20%", + "-20%", + "-20%", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-01-01", + "number": "4-1-1", + "name": "임업용 동력기계톱", + "level": 3, + "parent_code": "FP-04-01", + "sort_order": 23808, + "tables": [ + { + "pum_table_id": "F0083", + "section": "4-1-1. 임업용 동력기계톱", + "source_line": 1929, + "pum_form": "productivity", + "form_basis": "헤더 '/1인/1일'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "벌도목 구분", + "단목", + "전간목", + "전목" + ], + "condition_note": [ + "(단위 : ㎥/1인/1일)" + ], + "raw_row": [ + [ + "벌도목 구분", + "벌목량(㎥)", + "인력구분" + ], + [ + "단목", + "20.17", + "벌목부" + ], + [ + "전간목", + "25.50", + "" + ], + [ + "전목", + "40.34", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-01-02", + "number": "4-1-2", + "name": "하베스터(부착형 스트로크)", + "level": 3, + "parent_code": "FP-04-01", + "sort_order": 24064, + "tables": [ + { + "pum_table_id": "F0084", + "section": "4-1-2. 하베스터(부착형 스트로크)", + "source_line": 1947, + "pum_form": "productivity", + "form_basis": "헤더 '/1인/1일'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "벌도ㆍ조재 공정량(㎥)", + "59.20" + ], + "condition_note": [ + "(단위 : ㎥/1인/1일)" + ], + "raw_row": [ + [ + "벌도ㆍ조재 공정량(㎥)", + "인력구분" + ], + [ + "59.20", + "건설기계운전기사" + ] + ] + }, + { + "pum_table_id": "F0085", + "section": "4-1-2. 하베스터(부착형 스트로크)", + "source_line": 1957, + "pum_form": "requirement", + "form_basis": "값 단위 '(㎥)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "154" + ], + "condition_note": [ + "조재 공정량(㎥)", + "인력구분" + ], + "raw_row": [ + [ + "154", + "건설기계운전기사1인" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-02", + "number": "4-2", + "name": "단목베기", + "level": 2, + "parent_code": "FP-04", + "sort_order": 24320, + "tables": [ + { + "pum_table_id": "F0476", + "section": "4-2. 참나무시들음병방제", + "source_line": 9106, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 임․소반", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ경급별(cm)", + "10cm이하", + "12~14㎝", + "16~18㎝", + "20~22㎝", + "24~26㎝", + "28~30㎝", + "32~34㎝", + "ㆍ작업보조", + "ㆍ벌목작업안전 보조", + "- 재료비(체인톱)", + "ㆍ 보통휘발유(주연료)", + "ㆍ 보통휘발유(잡품)", + "ㆍ 체인오일(일반오일)", + "- 기계경비(체인톱)", + "2. 작업로설치", + "ㆍ 소작업로", + "ㆍ 대작업로", + "3. 산물수집", + "4. 집적" + ], + "condition_note": [ + "ha당 참나무시들음병방제 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 임․소반", + "1-0-1-0 또는 00임반 00소반", + "비고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원, 수량, 요율)", + "소요품", + "단가", + "할인․ 증률", + "계", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 단목베기", + "", + "", + "", + "", + "", + "", + "399,493", + "", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "378,072", + "", + "" + ], + [ + "ㆍ경급별(cm)", + "76", + "본/ha", + "", + "인/100본", + "0.88", + "248,681", + "벌목100%", + "20%", + "262,607", + "", + "" + ], + [ + "10cm이하", + "5", + "본/ha", + "0.40", + "인/100본", + "0.02", + "", + "", + "", + "", + "", + "" + ], + [ + "12~14㎝", + "11", + "본/ha", + "0.60", + "인/100본", + "0.07", + "", + "", + "", + "", + "", + "" + ], + [ + "16~18㎝", + "24", + "본/ha", + "1.00", + "인/100본", + "0.24", + "", + "", + "", + "", + "", + "" + ], + [ + "20~22㎝", + "18", + "본/ha", + "1.20", + "인/100본", + "0.22", + "", + "", + "", + "", + "", + "" + ], + [ + "24~26㎝", + "10", + "본/ha", + "1.40", + "인/100본", + "0.14", + "", + "", + "", + "", + "", + "" + ], + [ + "28~30㎝", + "5", + "본/ha", + "2.00", + "인/100본", + "0.10", + "", + "", + "", + "", + "", + "" + ], + [ + "32~34㎝", + "3", + "본/ha", + "2.90", + "인/100본", + "0.09", + "", + "", + "", + "", + "", + "" + ], + [ + "ㆍ작업보조", + "76", + "본", + "0.60", + "", + "0.46", + "169,804", + "보통인부", + "", + "78,109", + "", + "" + ], + [ + "ㆍ벌목작업안전 보조", + "0.88", + "인", + "", + "4인당1명", + "0.22", + "169,804", + "보통인부", + "", + "37,356", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "14,769", + "", + "" + ], + [ + "ㆍ 보통휘발유(주연료)", + "0.88", + "대", + "5.60", + "ℓ/대", + "4.93", + "1,537", + "무연(ℓ)", + "", + "7,577", + "", + "" + ], + [ + "ㆍ 보통휘발유(잡품)", + "7,577", + "원", + "40", + "%", + "", + "", + "", + "", + "3,030", + "", + "" + ], + [ + "ㆍ 체인오일(일반오일)", + "0.88", + "대", + "2.10", + "ℓ/대", + "1.85", + "2,250", + "국내산(ℓ)", + "", + "4,162", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "0.88", + "대", + "0.0084", + "손료계수", + "", + "900,000", + "체인톱(대)", + "", + "6,652", + "", + "" + ], + [ + "2. 작업로설치", + "", + "", + "", + "", + "", + "", + "", + "", + "227,515", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "216,565", + "", + "" + ], + [ + "ㆍ 소작업로", + "0.45", + "km", + "2.00", + "인/km", + "0.90", + "209,242", + "벌목50% 보통50%", + "15%", + "216,565", + "", + "" + ], + [ + "ㆍ 대작업로", + "-", + "〃", + "3.00", + "〃", + "-", + "(248,681)", + "(벌목부)", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "(169,804)", + "(보통인부)", + "", + "", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "7,548", + "", + "" + ], + [ + "ㆍ 보통휘발유(주연료)", + "0.45", + "대", + "5.60", + "ℓ/대", + "2.52", + "1,537", + "무연(ℓ)", + "", + "3,873", + "", + "" + ], + [ + "ㆍ 보통휘발유(잡품)", + "3,873", + "원", + "40", + "%", + "", + "", + "", + "", + "1,549", + "", + "" + ], + [ + "ㆍ 체인오일(일반오일)", + "0.45", + "대", + "2.10", + "ℓ/대", + "0.95", + "2,250", + "국내산(ℓ)", + "", + "2,126", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "0.45", + "대", + "0.0084", + "손료계수", + "", + "900,000", + "체인톱(대)", + "", + "3,402", + "", + "" + ], + [ + "3. 산물수집", + "", + "", + "", + "", + "", + "", + "", + "", + "1,371,337", + "", + "" + ], + [ + "- 직접노무비", + "17.85", + "㎥", + "2.65", + "㎥/인", + "6.73", + "169,804", + "보통100%", + "20%", + "1,371,337", + "", + "" + ], + [ + "4. 집적", + "", + "", + "", + "", + "", + "", + "", + "", + "157.624", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "106,848", + "", + "" + ], + [ + "ㆍ 우드그래플", + "13.169", + "", + "", + "", + "0.39", + "273,971", + "", + "", + "106,848", + "", + "" + ], + [ + "1.8m", + "", + "㎥", + "31.35", + "㎥/일", + "", + "273,971", + "건설기계운전사", + "", + "", + "", + "" + ], + [ + "2.1m", + "13.169", + "〃", + "33.55", + "〃", + "0.39", + "", + "", + "", + "", + "", + "" + ], + [ + "2.7m", + "", + "〃", + "36.32", + "〃", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "3.6m", + "", + "〃", + "41.29", + "〃", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "- 재료비(굴삭기)", + "", + "", + "", + "", + "", + "", + "", + "", + "15,676", + "", + "" + ], + [ + "ㆍ 경유(주연료)", + "0.39", + "일", + "20.80", + "ℓ/대", + "8.11", + "1,487", + "경유(ℓ)", + "", + "12,059", + "", + "" + ], + [ + "ㆍ 경유(잡품)", + "12,059", + "원", + "30%", + "", + "", + "", + "", + "", + "3,617", + "", + "" + ], + [ + "- 기계경비(굴삭기)", + "0.39", + "일", + "0.0015", + "1대/일", + "", + "60,000,000", + "우드그래플", + "", + "35,100", + "", + "" + ], + [ + "합 계", + "순원가", + "", + "", + "", + "", + "", + "", + "", + "", + "2,155,969", + "" + ], + [ + "o 노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "2,072,822", + "", + "" + ], + [ + "o 재료비", + "", + "", + "", + "", + "", + "", + "", + "", + "37,993", + "", + "" + ], + [ + "o 경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "45,154", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "단목베기", + "작업로설치", + "산물수집", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 집단화정도(1-4-3)", + "개소당 평균면적이 3ha 이상", + "0%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "급 (30°이상)", + "10%", + "10%", + "10%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 장애물의 정도(1-4-8)", + "가슴높이 이상의 초본·관목", + "10%", + "", + "10%", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 제거대상 식생(1-4-9)", + "보통이다 (높이 1.2m 이상이고, 직경이 4~6cm 미만)", + "", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "20%", + "15%", + "20%", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-02-01", + "number": "4-2-1", + "name": "100본당", + "level": 3, + "parent_code": "FP-04-02", + "sort_order": 24576, + "tables": [ + { + "pum_table_id": "F0086", + "section": "4-2-1. 100본당", + "source_line": 1967, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "벌목", + "10㎝이하", + "12~14㎝", + "16~18㎝", + "20~22㎝", + "24~26㎝", + "28~30㎝", + "32~34㎝", + "36~38㎝", + "40~42㎝", + "44~46㎝", + "48㎝ 이상", + "작업보조" + ], + "condition_note": [ + "제거 대상목 흉고직경", + "소요인력(인)", + "인력구분", + "비 고" + ], + "raw_row": [ + [ + "벌목", + "가지제거", + "토막내기", + "", + "", + "" + ], + [ + "10㎝이하", + "0.2", + "0.1", + "0.1", + "벌목부", + "" + ], + [ + "12~14㎝", + "0.4", + "0.1", + "0.1", + "", + "" + ], + [ + "16~18㎝", + "0.6", + "0.2", + "0.2", + "", + "" + ], + [ + "20~22㎝", + "0.7", + "0.2", + "0.3", + "", + "" + ], + [ + "24~26㎝", + "1.0", + "0.2", + "0.2", + "", + "" + ], + [ + "28~30㎝", + "1.2", + "0.4", + "0.4", + "", + "" + ], + [ + "32~34㎝", + "1.9", + "0.5", + "0.5", + "", + "" + ], + [ + "36~38㎝", + "2.4", + "0.7", + "0.7", + "", + "" + ], + [ + "40~42㎝", + "2.9", + "0.8", + "0.8", + "", + "" + ], + [ + "44~46㎝", + "3.6", + "1.1", + "1.1", + "", + "" + ], + [ + "48㎝ 이상", + "4.2", + "1.3", + "1.3", + "", + "" + ], + [ + "작업보조", + "0.6", + "보통인부", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-02-02", + "number": "4-2-2", + "name": "1,000㎡당", + "level": 3, + "parent_code": "FP-04-02", + "sort_order": 24832, + "tables": [ + { + "pum_table_id": "F0087", + "section": "4-2-2. 1,000㎡당", + "source_line": 1997, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "5m 미만", + "벌목부 보통인부", + "굴착기+부착용집게", + "비고" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "나무높이" + ], + "raw_row": [ + [ + "5m 미만", + "5m이상~8m미만", + "8m 이상", + "", + "", + "" + ], + [ + "벌목부 보통인부", + "", + "인 인", + "2.14 0.51", + "2.80 0.66", + "3.65 0.87" + ], + [ + "굴착기+부착용집게", + "0.2㎥", + "hr", + "2.71", + "3.54", + "4.61" + ], + [ + "비고", + "- 본 품의 집재거리는 100m까지를 기준한 것이므로, 이를 초과하는 경우 매 100m 증가마다 인력품을 30%씩 가산한다.", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-03", + "number": "4-3", + "name": "위험목 베기", + "level": 2, + "parent_code": "FP-04", + "sort_order": 25088, + "tables": [ + { + "pum_table_id": "F0088", + "section": "4-3. 위험목 베기", + "source_line": 2012, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "16~20cm", + "22~30cm", + "32~40cm", + "42~50cm", + "52cm이상" + ], + "condition_note": [ + "가슴높이지름 (cm)", + "소요인력(인/본당)", + "소요시간(hr)" + ], + "raw_row": [ + [ + "특별인부", + "벌목부", + "보통인부", + "굴 삭 기 우드그랩", + "크레인", + "" + ], + [ + "16~20cm", + "0.14", + "0.28", + "0.28", + "0.28", + "0.28" + ], + [ + "22~30cm", + "0.18", + "0.36", + "0.36", + "0.36", + "0.36" + ], + [ + "32~40cm", + "0.27", + "0.53", + "0.53", + "0.53", + "0.53" + ], + [ + "42~50cm", + "0.35", + "0.70", + "0.70", + "0.70", + "0.70" + ], + [ + "52cm이상", + "0.38", + "0.75", + "0.75", + "0.75", + "0.75" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-04", + "number": "4-4", + "name": "가지정리", + "level": 2, + "parent_code": "FP-04", + "sort_order": 25344, + "tables": [ + { + "pum_table_id": "F0089", + "section": "4-4. 가지정리", + "source_line": 2041, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부" + ], + "condition_note": [ + "명 칭", + "1종", + "2종" + ], + "raw_row": [ + [ + "보통인부", + "0.42", + "0.71" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-05", + "number": "4-5", + "name": "벌도 위험목 점검", + "level": 2, + "parent_code": "FP-04", + "sort_order": 25600, + "tables": [ + { + "pum_table_id": "F0090", + "section": "4-5. 벌도 위험목 점검", + "source_line": 2052, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "0.13인" + ], + "condition_note": [ + "소요인력(인)", + "인력구분" + ], + "raw_row": [ + [ + "0.13인", + "벌목부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-04-06", + "number": "4-6", + "name": "벌목부 작업안전 보조", + "level": 2, + "parent_code": "FP-04", + "sort_order": 25856, + "tables": [ + { + "pum_table_id": "F0091", + "section": "4-6. 벌목부 작업안전 보조", + "source_line": 2062, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "1인" + ], + "condition_note": [ + "소요인력(인)", + "인력구분" + ], + "raw_row": [ + [ + "1인", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05", + "number": "5", + "name": "식재 및 녹화", + "level": 1, + "parent_code": null, + "sort_order": 26112, + "tables": [] + }, + { + "work_item_code": "FP-05-01", + "number": "5-1", + "name": "굴취작업", + "level": 2, + "parent_code": "FP-05", + "sort_order": 26368, + "tables": [] + }, + { + "work_item_code": "FP-05-01-01", + "number": "5-1-1", + "name": "관목굴취", + "level": 3, + "parent_code": "FP-05-01", + "sort_order": 26624, + "tables": [ + { + "pum_table_id": "F0092", + "section": "5-1-1. 관목굴취", + "source_line": 2081, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "0.3m 미만", + "특 별 인 부", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "수 량(수고기준)" + ], + "raw_row": [ + [ + "0.3m 미만", + "0.3∼0.7m 이하", + "0.8∼1.1m 이하", + "1.2∼1.5m 이하", + "" + ], + [ + "특 별 인 부", + "0.084", + "0.168", + "0.264", + "0.408" + ], + [ + "보 통 인 부", + "0.012", + "0.036", + "0.048", + "0.072" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-01-02", + "number": "5-1-2", + "name": "교목굴취(나무높이)", + "level": 3, + "parent_code": "FP-05-01", + "sort_order": 26880, + "tables": [ + { + "pum_table_id": "F0093", + "section": "5-1-2. 교목굴취(나무높이)", + "source_line": 2099, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "1.0 이하", + "1.1∼1.5", + "1.6∼2.0", + "2.1∼2.5", + "2.6∼3.0", + "3.1∼3.5", + "3.6∼4.0", + "4.1∼4.5", + "4.6∼5.0", + "비 고" + ], + "condition_note": [ + "나무높이(m)", + "수 량" + ], + "raw_row": [ + [ + "특별인부", + "보통인부", + "" + ], + [ + "1.0 이하", + "0.072", + "0.012" + ], + [ + "1.1∼1.5", + "0.084", + "0.024" + ], + [ + "1.6∼2.0", + "0.096", + "0.024" + ], + [ + "2.1∼2.5", + "0.120", + "0.036" + ], + [ + "2.6∼3.0", + "0.132", + "0.036" + ], + [ + "3.1∼3.5", + "0.156", + "0.036" + ], + [ + "3.6∼4.0", + "0.180", + "0.048" + ], + [ + "4.1∼4.5", + "0.204", + "0.048" + ], + [ + "4.6∼5.0", + "0.228", + "0.060" + ], + [ + "비 고", + "분이 없는 경우 굴취품의 20%를 감한다.", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-01-03", + "number": "5-1-3", + "name": "교목굴취(근원직경)", + "level": 3, + "parent_code": "FP-05-01", + "sort_order": 27136, + "tables": [ + { + "pum_table_id": "F0094", + "section": "5-1-3. 교목굴취(근원직경)", + "source_line": 2125, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "특별인부(인)", + "4이하", + "5(4이하)", + "6 ∼ 7(5 ∼ 6)", + "8 ∼ 9(7 ∼ 8)", + "10 ∼ 11(9)", + "12 ∼ 14(10 ∼ 12)", + "15 ∼ 17(13 ∼ 14)", + "18 ∼ 19(15 ∼ 16)", + "20 ∼ 24(17 ∼ 20)", + "25 ∼ 29(21 ∼ 24)", + "30 ∼ 34(25 ∼ 28)", + "35 ∼ 39(29 ∼ 32)", + "40 ∼ 44(33 ∼ 37)", + "45 ∼ 49(38 ∼ 41)", + "50 ∼ 54(42 ∼ 45)", + "55 ∼ 59(46 ∼ 49)", + "60(50)", + "비 고" + ], + "condition_note": [ + "근원(흉고)직경(㎝)", + "수량" + ], + "raw_row": [ + [ + "특별인부(인)", + "보통인부(인)", + "굴착기(시간)", + "크레인(시간)", + "" + ], + [ + "4이하", + "0.096", + "0.024", + "", + "" + ], + [ + "5(4이하)", + "0.120", + "0.036", + "", + "" + ], + [ + "6 ∼ 7(5 ∼ 6)", + "0.204", + "0.048", + "", + "" + ], + [ + "8 ∼ 9(7 ∼ 8)", + "0.324", + "0.084", + "", + "" + ], + [ + "10 ∼ 11(9)", + "0.180", + "0.072", + "0.588", + "" + ], + [ + "12 ∼ 14(10 ∼ 12)", + "0.312", + "0.096", + "0.708", + "" + ], + [ + "15 ∼ 17(13 ∼ 14)", + "0.480", + "0.120", + "0.852", + "" + ], + [ + "18 ∼ 19(15 ∼ 16)", + "0.612", + "0.132", + "0.972", + "" + ], + [ + "20 ∼ 24(17 ∼ 20)", + "0.804", + "0.156", + "1.140", + "0.228" + ], + [ + "25 ∼ 29(21 ∼ 24)", + "1.080", + "0.192", + "1.380", + "0.276" + ], + [ + "30 ∼ 34(25 ∼ 28)", + "1.344", + "0.228", + "1.620", + "0.324" + ], + [ + "35 ∼ 39(29 ∼ 32)", + "1.620", + "0.264", + "1.860", + "0.372" + ], + [ + "40 ∼ 44(33 ∼ 37)", + "1.884", + "0.300", + "2.088", + "0.420" + ], + [ + "45 ∼ 49(38 ∼ 41)", + "2.160", + "0.336", + "2.328", + "0.468" + ], + [ + "50 ∼ 54(42 ∼ 45)", + "2.424", + "0.372", + "2.568", + "0.516" + ], + [ + "55 ∼ 59(46 ∼ 49)", + "2.700", + "0.408", + "2.808", + "0.564" + ], + [ + "60(50)", + "2.856", + "0.432", + "2.952", + "0.600" + ], + [ + "비 고", + "분이 없는 경우 굴취품의 20%를 감한다.", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0095", + "section": "5-1-3. 교목굴취(근원직경)", + "source_line": 2156, + "pum_form": "requirement", + "form_basis": "값 단위 '(㎥)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼ 19", + "20 ∼ 26", + "27 ∼ 39", + "40 ∼ 60" + ], + "condition_note": [ + "근원직경(cm)", + "굴착기(㎥)", + "크레인" + ], + "raw_row": [ + [ + "10∼ 19", + "0.4", + "-" + ], + [ + "20 ∼ 26", + "0.6", + "트럭탑재형 크레인 10ton" + ], + [ + "27 ∼ 39", + "0.6", + "트럭탑재형 크레인 15ton" + ], + [ + "40 ∼ 60", + "0.6", + "크레인(타이어) 25 ∼ 50ton" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-01-04", + "number": "5-1-4", + "name": "떼채취", + "level": 3, + "parent_code": "FP-05-01", + "sort_order": 27392, + "tables": [ + { + "pum_table_id": "F0096", + "section": "5-1-4. 떼채취", + "source_line": 2167, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 100.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "줄떼", + "평떼" + ], + "condition_note": [ + "구 분", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "줄떼", + "3.0", + "" + ], + [ + "평떼", + "6.0", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-01-05", + "number": "5-1-5", + "name": "떼운반 적재 기준표", + "level": 3, + "parent_code": "FP-05-01", + "sort_order": 27648, + "tables": [ + { + "pum_table_id": "F0097", + "section": "5-1-5. 떼운반 적재 기준표", + "source_line": 2177, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "지게", + "리어카", + "우마차", + "2.5톤 트럭", + "6톤 트럭", + "8톤 트럭" + ], + "condition_note": [ + "구 분", + "줄떼(매)", + "평떼(매)", + "싣고부리기시간(분)", + "싣고부리기인부(인)", + "비 고" + ], + "raw_row": [ + [ + "지게", + "30", + "10", + "2", + "1", + "" + ], + [ + "리어카", + "150", + "50", + "5", + "2", + "" + ], + [ + "우마차", + "480", + "160", + "13", + "2", + "" + ], + [ + "2.5톤 트럭", + "1,500", + "500", + "20", + "5", + "" + ], + [ + "6톤 트럭", + "3,600", + "1,200", + "50", + "5", + "" + ], + [ + "8톤 트럭", + "4,800", + "1,600", + "60", + "5", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-02", + "number": "5-2", + "name": "뿌리돌림", + "level": 2, + "parent_code": "FP-05", + "sort_order": 27904, + "tables": [ + { + "pum_table_id": "F0098", + "section": "5-2. 뿌리돌림", + "source_line": 2194, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "3", + "5", + "7", + "9", + "11", + "13", + "15", + "18", + "21", + "24", + "30" + ], + "condition_note": [ + "근원직경(㎝)", + "수 량", + "근원직경(㎝)", + "수 량" + ], + "raw_row": [ + [ + "특별인부", + "보통인부", + "특별인부", + "보통인부", + "", + "" + ], + [ + "3", + "0.03", + "0.01", + "36", + "1.86", + "0.22" + ], + [ + "5", + "0.06", + "0.01", + "42", + "2.04", + "0.25" + ], + [ + "7", + "0.11", + "0.01", + "48", + "2.32", + "0.28" + ], + [ + "9", + "0.17", + "0.02", + "54", + "2.79", + "0.33" + ], + [ + "11", + "0.23", + "0.03", + "60", + "3.07", + "0.36" + ], + [ + "13", + "0.30", + "0.03", + "66", + "4.18", + "0.50" + ], + [ + "15", + "0.37", + "0.05", + "72", + "4.65", + "0.55" + ], + [ + "18", + "0.56", + "0.06", + "78", + "5.21", + "0.62" + ], + [ + "21", + "0.65", + "0.08", + "84", + "6.51", + "0.78" + ], + [ + "24", + "0.74", + "0.09", + "90", + "7.06", + "0.85" + ], + [ + "30", + "1.58", + "0.19", + "100", + "7.90", + "0.95" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-03", + "number": "5-3", + "name": "식재작업", + "level": 2, + "parent_code": "FP-05", + "sort_order": 28160, + "tables": [] + }, + { + "work_item_code": "FP-05-03-01", + "number": "5-3-1", + "name": "나무식재", + "level": 3, + "parent_code": "FP-05-03", + "sort_order": 28416, + "tables": [ + { + "pum_table_id": "F0099", + "section": "5-3-1. 나무식재", + "source_line": 2219, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소묘식재", + "중묘식재", + "대묘식재" + ], + "condition_note": [ + "구 분", + "규 격", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "소묘식재", + "소 묘", + "3.50", + "특별인부 30% 보통인부 70%" + ], + [ + "중묘식재", + "중 묘", + "4.00", + "" + ], + [ + "대묘식재", + "대 묘", + "5.00", + "" + ] + ] + }, + { + "pum_table_id": "F0100", + "section": "5-3-1. 나무식재", + "source_line": 2230, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소나무", + "낙엽송", + "잣나무", + "편백", + "삼나무", + "리기테다소나무", + "백합나무", + "가시나무류", + "※ 위 표에서 ‘노’는 ‘노지묘’이고, ‘용’은 ‘용기묘’이다. ※ 그 밖의 수종은 수종별 간장(최소 간장을 말한다) 및 근원경을 기준으로 아래와 같이 적용하되, 활엽수 삽목묘ㆍ접목묘 대묘는 이를 중묘로 본다. 구분침엽수활엽수소묘간장 20cm미만간장 30cm미만중묘간장 20cm이상 40cm미만 또는간장 40cm이상이고 근원경 8mm미만간장 30cm이상 60cm미만 또는간장 60cm이상이고 근원경 11mm미만대묘간장 40cm이상이고 근원경 8mm이상간장 60cm이상이고 근원경 11mm이상 구분 침엽수 활엽수 소묘 간장 20cm미만 간장 30cm미만 중묘 간장 20cm이상 40cm미만 또는 간장 40cm이상이고 근원경 8mm미만 간장 30cm이상 60cm미만 또는 간장 60cm이상이고 근원경 11mm미만 대묘 간장 40cm이상이고 근원경 8mm이상 간장 60cm이상이고 근원경 11mm이상" + ], + "condition_note": [ + "수종별", + "소 묘", + "중 묘", + "대 묘" + ], + "raw_row": [ + [ + "소나무", + "1-1(노)", + "2-0(용)", + "1-1-2(노), 2-2(용)" + ], + [ + "낙엽송", + "", + "1-1, 2-0(용)", + "" + ], + [ + "잣나무", + "2-1(노)", + "2-2(노)", + "2-2-3(노)" + ], + [ + "편백", + "", + "1-1(노), 1-1-1(노), 2-0(용)", + "1-1-2, 2-2(용)" + ], + [ + "삼나무", + "", + "1-1(노)", + "" + ], + [ + "리기테다소나무", + "1-0(노)", + "", + "" + ], + [ + "백합나무", + "1-0(노), 1-1(노)", + "", + "" + ], + [ + "가시나무류", + "2-0(용)", + "", + "" + ], + [ + "※ 위 표에서 ‘노’는 ‘노지묘’이고, ‘용’은 ‘용기묘’이다. ※ 그 밖의 수종은 수종별 간장(최소 간장을 말한다) 및 근원경을 기준으로 아래와 같이 적용하되, 활엽수 삽목묘ㆍ접목묘 대묘는 이를 중묘로 본다. 구분침엽수활엽수소묘간장 20cm미만간장 30cm미만중묘간장 20cm이상 40cm미만 또는간장 40cm이상이고 근원경 8mm미만간장 30cm이상 60cm미만 또는간장 60cm이상이고 근원경 11mm미만대묘간장 40cm이상이고 근원경 8mm이상간장 60cm이상이고 근원경 11mm이상 구분 침엽수 활엽수 소묘 간장 20cm미만 간장 30cm미만 중묘 간장 20cm이상 40cm미만 또는 간장 40cm이상이고 근원경 8mm미만 간장 30cm이상 60cm미만 또는 간장 60cm이상이고 근원경 11mm미만 대묘 간장 40cm이상이고 근원경 8mm이상 간장 60cm이상이고 근원경 11mm이상", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0101", + "section": "5-3-1. 나무식재", + "source_line": 2242, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소묘", + "중묘", + "대묘" + ], + "condition_note": [ + "구분", + "침엽수", + "활엽수" + ], + "raw_row": [ + [ + "소묘", + "간장 20cm미만", + "간장 30cm미만" + ], + [ + "중묘", + "간장 20cm이상 40cm미만 또는 간장 40cm이상이고 근원경 8mm미만", + "간장 30cm이상 60cm미만 또는 간장 60cm이상이고 근원경 11mm미만" + ], + [ + "대묘", + "간장 40cm이상이고 근원경 8mm이상", + "간장 60cm이상이고 근원경 11mm이상" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-03-02", + "number": "5-3-2", + "name": "관목식재(단식)", + "level": 3, + "parent_code": "FP-05-03", + "sort_order": 28672, + "tables": [ + { + "pum_table_id": "F0102", + "section": "5-3-2. 관목식재(단식)", + "source_line": 2254, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "0.3m 미만", + "특 별 인 부", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "수 량(수고기준)" + ], + "raw_row": [ + [ + "0.3m 미만", + "0.3∼0.7m 이하", + "0.8∼1.1m 이하", + "1.2∼1.5m 이하", + "" + ], + [ + "특 별 인 부", + "0.19", + "0.24", + "0.40", + "0.57" + ], + [ + "보 통 인 부", + "0.06", + "0.08", + "0.13", + "0.18" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-03-03", + "number": "5-3-3", + "name": "관목식재(군식)", + "level": 3, + "parent_code": "FP-05-03", + "sort_order": 28928, + "tables": [ + { + "pum_table_id": "F0103", + "section": "5-3-3. 관목식재(군식)", + "source_line": 2272, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "0.3m 미만", + "특 별 인 부", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "수 량(수고기준)" + ], + "raw_row": [ + [ + "0.3m 미만", + "0.3∼0.7m 이하", + "0.8∼1.1m 이하", + "1.2∼1.5m 이하", + "" + ], + [ + "특 별 인 부", + "0.07", + "0.10", + "0.15", + "0.21" + ], + [ + "보 통 인 부", + "0.02", + "0.03", + "0.05", + "0.07" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-03-04", + "number": "5-3-4", + "name": "교목식재(나무높이)", + "level": 3, + "parent_code": "FP-05-03", + "sort_order": 29184, + "tables": [ + { + "pum_table_id": "F0104", + "section": "5-3-4. 교목식재(나무높이)", + "source_line": 2291, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인 력 시 공", + "특별인부(인)", + "1.0 이하", + "1.1 ∼ 1.5", + "1.6 ∼ 2.0", + "2.1 ∼ 2.5", + "2.6 ∼ 3.0", + "3.1 ∼ 3.5", + "3.6 ∼ 4.0", + "4.1 ∼ 4.5", + "4.6 ∼ 5.0", + "비고" + ], + "condition_note": [ + "나 무 높 이(m)", + "수 량" + ], + "raw_row": [ + [ + "인 력 시 공", + "기 계 시 공", + "", + "", + "", + "" + ], + [ + "특별인부(인)", + "보통인부(인)", + "특별인부(인)", + "보통인부(인)", + "굴착기(시간)", + "" + ], + [ + "1.0 이하", + "0.07", + "0.06", + "-", + "-", + "-" + ], + [ + "1.1 ∼ 1.5", + "0.09", + "0.07", + "-", + "-", + "-" + ], + [ + "1.6 ∼ 2.0", + "0.11", + "0.09", + "-", + "-", + "-" + ], + [ + "2.1 ∼ 2.5", + "0.15", + "0.12", + "0.10", + "0.06", + "0.19" + ], + [ + "2.6 ∼ 3.0", + "0.19", + "0.14", + "0.11", + "0.07", + "0.23" + ], + [ + "3.1 ∼ 3.5", + "0.23", + "0.17", + "0.13", + "0.07", + "0.26" + ], + [ + "3.6 ∼ 4.0", + "0.29", + "0.20", + "0.15", + "0.08", + "0.31" + ], + [ + "4.1 ∼ 4.5", + "0.33", + "0.23", + "0.16", + "0.09", + "0.35" + ], + [ + "4.6 ∼ 5.0", + "0.38", + "0.27", + "0.17", + "0.10", + "0.40" + ], + [ + "비고", + "- 지주목을 세우지 않을 때는 다음의 요율을 감한다. 인력시공시기계시공시인력품의 10%인력품의 20% 인력시공시 기계시공시 인력품의 10% 인력품의 20%", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0105", + "section": "5-3-4. 교목식재(나무높이)", + "source_line": 2306, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인력품의 10%" + ], + "condition_note": [ + "인력시공시", + "기계시공시" + ], + "raw_row": [ + [ + "인력품의 10%", + "인력품의 20%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-03-05", + "number": "5-3-5", + "name": "교목식재(흉고직경)", + "level": 3, + "parent_code": "FP-05-03", + "sort_order": 29440, + "tables": [ + { + "pum_table_id": "F0106", + "section": "5-3-5. 교목식재(흉고직경)", + "source_line": 2322, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "특별인부(인)", + "4(5)이하", + "5(6)", + "6 ∼ 7(7 ∼ 8)", + "8 ∼ 9(9 ∼ 11)", + "10 ∼ 11(12 ∼ 13)", + "12 ∼ 14(14 ∼ 17)", + "15 ∼ 17(18 ∼ 20)", + "18 ∼ 19(21 ∼ 23)", + "20 ∼ 24(24 ∼ 29)", + "25 ∼ 29(30 ∼ 35)", + "30 ∼ 34(36 ∼ 41)", + "35 ∼ 39(42 ∼ 47)", + "40 ∼ 44(48 ∼ 53)", + "45 ∼ 49(54 ∼ 59)", + "50(60)", + "비고" + ], + "condition_note": [ + "흉고(근원)직경(㎝)", + "수 량" + ], + "raw_row": [ + [ + "특별인부(인)", + "보통인부(인)", + "굴착기(시간)", + "크레인(시간)", + "" + ], + [ + "4(5)이하", + "0.10", + "0.06", + "-", + "-" + ], + [ + "5(6)", + "0.17", + "0.08", + "-", + "-" + ], + [ + "6 ∼ 7(7 ∼ 8)", + "0.26", + "0.13", + "-", + "-" + ], + [ + "8 ∼ 9(9 ∼ 11)", + "0.19", + "0.11", + "0.37", + "-" + ], + [ + "10 ∼ 11(12 ∼ 13)", + "0.24", + "0.13", + "0.43", + "-" + ], + [ + "12 ∼ 14(14 ∼ 17)", + "0.31", + "0.15", + "0.52", + "-" + ], + [ + "15 ∼ 17(18 ∼ 20)", + "0.39", + "0.17", + "0.64", + "-" + ], + [ + "18 ∼ 19(21 ∼ 23)", + "0.47", + "0.20", + "0.72", + "0.21" + ], + [ + "20 ∼ 24(24 ∼ 29)", + "0.56", + "0.22", + "0.85", + "0.26" + ], + [ + "25 ∼ 29(30 ∼ 35)", + "0.69", + "0.26", + "1.03", + "0.34" + ], + [ + "30 ∼ 34(36 ∼ 41)", + "0.83", + "0.30", + "1.21", + "0.42" + ], + [ + "35 ∼ 39(42 ∼ 47)", + "0.97", + "0.35", + "1.39", + "0.50" + ], + [ + "40 ∼ 44(48 ∼ 53)", + "1.11", + "0.38", + "1.56", + "0.58" + ], + [ + "45 ∼ 49(54 ∼ 59)", + "1.24", + "0.43", + "1.75", + "0.66" + ], + [ + "50(60)", + "1.33", + "0.45", + "1.85", + "0.70" + ], + [ + "비고", + "- 지주목을 세우지 않을 때는 다음의 요율을 감한다. 인력시공시기계시공시인력품의 10%인력품의 20% 인력시공시 기계시공시 인력품의 10% 인력품의 20%", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0107", + "section": "5-3-5. 교목식재(흉고직경)", + "source_line": 2342, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인력품의 10%" + ], + "condition_note": [ + "인력시공시", + "기계시공시" + ], + "raw_row": [ + [ + "인력품의 10%", + "인력품의 20%" + ] + ] + }, + { + "pum_table_id": "F0108", + "section": "5-3-5. 교목식재(흉고직경)", + "source_line": 2355, + "pum_form": "requirement", + "form_basis": "값 단위 '(㎥)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "8 ∼ 17", + "18 ∼ 22", + "23 ∼ 34", + "35 ∼ 50" + ], + "condition_note": [ + "흉고직경(cm)", + "굴착기(㎥)", + "크레인" + ], + "raw_row": [ + [ + "8 ∼ 17", + "0.4", + "-" + ], + [ + "18 ∼ 22", + "0.6", + "트럭탑재형 크레인 10ton" + ], + [ + "23 ∼ 34", + "0.6", + "트력탑재형 크레인 15ton" + ], + [ + "35 ∼ 50", + "0.6", + "크레인(타이어) 25 ∼ 50ton" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-04", + "number": "5-4", + "name": "파종조림", + "level": 2, + "parent_code": "FP-05", + "sort_order": 29696, + "tables": [ + { + "pum_table_id": "F0109", + "section": "5-4. 파종조림", + "source_line": 2366, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "파종상 만들기", + "80cm×80cm×5,000개", + "파종상에 점파", + "파종상 없이", + "점 파" + ], + "condition_note": [ + "구 분", + "내 용", + "소요인력 (인/ha)", + "인력구분" + ], + "raw_row": [ + [ + "파종상 만들기", + "40cm×40cm×5,000개", + "6", + "보통인부" + ], + [ + "80cm×80cm×5,000개", + "11", + "", + "" + ], + [ + "파종상에 점파", + "5,000판×3립", + "7.2", + "" + ], + [ + "파종상 없이", + "조 파", + "9.2", + "" + ], + [ + "점 파", + "7.2", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-05", + "number": "5-5", + "name": "천연하종갱신", + "level": 2, + "parent_code": "FP-05", + "sort_order": 29952, + "tables": [ + { + "pum_table_id": "F0110", + "section": "5-5. 천연하종갱신", + "source_line": 2383, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "지면긁기작업", + "폭 80cm × 열간거리 2m (전면적의 40%)" + ], + "condition_note": [ + "구 분", + "내 용", + "소요인력 (인/ha)", + "인력구분" + ], + "raw_row": [ + [ + "지면긁기작업", + "폭 30~40cm × 열간거리 2m (전면적의 15~20%)", + "8", + "보통인부" + ], + [ + "폭 80cm × 열간거리 2m (전면적의 40%)", + "15", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-06", + "number": "5-6", + "name": "움싹갱신", + "level": 2, + "parent_code": "FP-05", + "sort_order": 30208, + "tables": [ + { + "pum_table_id": "F0111", + "section": "5-6. 움싹갱신", + "source_line": 2396, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "맹아근주 정리작업" + ], + "condition_note": [ + "구 분", + "내 용", + "소요인력 (인/ha)", + "인력구분" + ], + "raw_row": [ + [ + "맹아근주 정리작업", + "맹아근주 900본 기준", + "4.95", + "특별인부 50% 보통인부 50%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-07", + "number": "5-7", + "name": "생태보완조림", + "level": 2, + "parent_code": "FP-05", + "sort_order": 30464, + "tables": [ + { + "pum_table_id": "F0112", + "section": "5-7. 생태보완조림", + "source_line": 2409, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "움싹본수조절", + "치수이식", + "보완조림" + ], + "condition_note": [ + "구 분", + "내 용", + "소요인력(인/ha)", + "인력구분" + ], + "raw_row": [ + [ + "움싹본수조절", + "그루터기 900본 기준", + "4.15", + "특별인부 30% 보통인부 70%" + ], + [ + "치수이식", + "치수 100본 기준", + "0.50", + "" + ], + [ + "보완조림", + "현장 여건에 맞게 적용", + "묘목규격에 따라 차등적용", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-08", + "number": "5-8", + "name": "큰나무 공익조림", + "level": 2, + "parent_code": "FP-05", + "sort_order": 30720, + "tables": [ + { + "pum_table_id": "F0113", + "section": "5-8. 큰나무 공익조림", + "source_line": 2426, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "큰나무 식재" + ], + "condition_note": [ + "구 분", + "규 격", + "소요인력 (인/본)", + "인력구분" + ], + "raw_row": [ + [ + "큰나무 식재", + "R2㎝(h1.0∼1.5m)이상", + "0.16", + "특별인부 30% 보통인부 70%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-09", + "number": "5-9", + "name": "해안조림", + "level": 2, + "parent_code": "FP-05", + "sort_order": 30976, + "tables": [ + { + "pum_table_id": "F0114", + "section": "5-9. 해안조림", + "source_line": 2439, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부" + ], + "condition_note": [ + "구 분", + "식 혈", + "식 재", + "비 고" + ], + "raw_row": [ + [ + "보통인부", + "0.47", + "0.17", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-10", + "number": "5-10", + "name": "사방조림", + "level": 2, + "parent_code": "FP-05", + "sort_order": 31232, + "tables": [ + { + "pum_table_id": "F0115", + "section": "5-10. 사방조림", + "source_line": 2456, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "㏊", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "묘목", + "요소", + "인산", + "운반비", + "특별인부", + "보통인부" + ], + "condition_note": [ + "명 칭", + "규 격", + "수 량", + "단 위", + "비 고" + ], + "raw_row": [ + [ + "묘목", + "1~0", + "4,000", + "본", + "묘목 : 1년생 본수 : 4,000본" + ], + [ + "요소", + "46%", + "", + "kg", + "" + ], + [ + "인산", + "20%", + "", + "kg", + "" + ], + [ + "운반비", + "", + "72.4", + "kg", + "별도 계상, 운반조견표 참조" + ], + [ + "특별인부", + "", + "1.33", + "인", + "" + ], + [ + "보통인부", + "", + "20", + "인", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-11", + "number": "5-11", + "name": "사초심기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 31488, + "tables": [ + { + "pum_table_id": "F0116", + "section": "5-11. 사초심기", + "source_line": 2475, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부(인)" + ], + "condition_note": [ + "구 분", + "사초굴취", + "사초식재", + "비 고" + ], + "raw_row": [ + [ + "보통인부(인)", + "0.43", + "0.72", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-12", + "number": "5-12", + "name": "떼붙임(재배잔디)", + "level": 2, + "parent_code": "FP-05", + "sort_order": 31744, + "tables": [ + { + "pum_table_id": "F0117", + "section": "5-12. 떼붙임(재배잔디)", + "source_line": 2496, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "줄떼", + "평떼" + ], + "condition_note": [ + "구 분", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "줄떼", + "4.0~5.0", + "" + ], + [ + "평떼", + "5.0~7.0", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-13", + "number": "5-13", + "name": "떼심기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 32000, + "tables": [ + { + "pum_table_id": "F0118", + "section": "5-13. 떼심기", + "source_line": 2511, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "줄떼심기", + "띠떼심기" + ], + "condition_note": [ + "구 분", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "줄떼심기", + "4.5", + "평탄지용" + ], + [ + "띠떼심기", + "6.0", + "경사지용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-14", + "number": "5-14", + "name": "새심기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 32256, + "tables": [ + { + "pum_table_id": "F0119", + "section": "5-14. 새심기", + "source_line": 2526, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "새채집", + "요소", + "인산", + "보통인부 (채집)", + "보통인부 (심기)", + "새운반" + ], + "condition_note": [ + "공정별", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "새채집", + "㎡", + "1", + "1㎡당 10주, 1주는 5본기준" + ], + [ + "요소", + "kg", + "0.02", + "" + ], + [ + "인산", + "kg", + "0.126", + "" + ], + [ + "보통인부 (채집)", + "인", + "0.0328", + "" + ], + [ + "보통인부 (심기)", + "인", + "0.0328", + "" + ], + [ + "새운반", + "㎡", + "1", + "조견표에 의함" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-15", + "number": "5-15", + "name": "바자얽기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 32512, + "tables": [ + { + "pum_table_id": "F0120", + "section": "5-15. 바자얽기", + "source_line": 2546, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "말뚝", + "보통인부", + "특별인부" + ], + "condition_note": [ + "명 칭", + "규 격", + "수 량", + "단 위", + "비 고" + ], + "raw_row": [ + [ + "말뚝", + "직경4~6㎝, 길이120㎝ 기준", + "20", + "개", + "" + ], + [ + "보통인부", + "", + "3.26", + "인", + "단절고 0.59m, 계단폭 0.7m" + ], + [ + "특별인부", + "", + "0.22", + "인", + "말뚝 1m당 2개 사용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-16", + "number": "5-16", + "name": "선떼붙이기공", + "level": 2, + "parent_code": "FP-05", + "sort_order": 32768, + "tables": [] + }, + { + "work_item_code": "FP-05-16-01", + "number": "5-16-1", + "name": "단끊기", + "level": 3, + "parent_code": "FP-05-16", + "sort_order": 33024, + "tables": [ + { + "pum_table_id": "F0121", + "section": "5-16-1. 단끊기", + "source_line": 2565, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통토사", + "절 취", + "수평잡기 및 단정리", + "잡석 및 뿌리 정리", + "절·성토면 고르기", + "합계" + ], + "condition_note": [ + "공정별", + "보통인부(인)", + "비고" + ], + "raw_row": [ + [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사", + "", + "" + ], + [ + "절 취", + "2.4", + "3.3", + "5.4", + "" + ], + [ + "수평잡기 및 단정리", + "0.34", + "0.34", + "0.34", + "" + ], + [ + "잡석 및 뿌리 정리", + "0.36", + "0.36", + "0.36", + "" + ], + [ + "절·성토면 고르기", + "1.17", + "1.17", + "1.17", + "" + ], + [ + "합계", + "2.03", + "2.09", + "2.23", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-16-02", + "number": "5-16-2", + "name": "선떼붙이기", + "level": 3, + "parent_code": "FP-05-16", + "sort_order": 33280, + "tables": [ + { + "pum_table_id": "F0122", + "section": "5-16-2. 선떼붙이기", + "source_line": 2585, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 10.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "단끊기", + "떼붙임", + "떼운반", + "떼 지게운반" + ], + "condition_note": [ + "공정별", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "단끊기", + "m", + "1", + "" + ], + [ + "떼붙임", + "㎡", + "물량산출", + "" + ], + [ + "떼운반", + "㎡", + "물량산출", + "" + ], + [ + "떼 지게운반", + "㎡", + "물량산출", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-17", + "number": "5-17", + "name": "조공", + "level": 2, + "parent_code": "FP-05", + "sort_order": 33536, + "tables": [] + }, + { + "work_item_code": "FP-05-17-01", + "number": "5-17-1", + "name": "떼조공", + "level": 3, + "parent_code": "FP-05-17", + "sort_order": 33792, + "tables": [ + { + "pum_table_id": "F0123", + "section": "5-17-1. 떼조공", + "source_line": 2603, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 100.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "단끊기", + "줄떼심기", + "떼운반", + "떼 지게운반" + ], + "condition_note": [ + "공정별", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "단끊기", + "m", + "1", + "" + ], + [ + "줄떼심기", + "㎡", + "물량산출", + "" + ], + [ + "떼운반", + "㎡", + "물량산출", + "" + ], + [ + "떼 지게운반", + "㎡", + "물량산출", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-17-02", + "number": "5-17-2", + "name": "돌조공", + "level": 3, + "parent_code": "FP-05-17", + "sort_order": 34048, + "tables": [ + { + "pum_table_id": "F0124", + "section": "5-17-2. 돌조공", + "source_line": 2617, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "단끊기", + "돌쌓기", + "돌운반" + ], + "condition_note": [ + "공정별", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "단끊기", + "m", + "1", + "단끊기 품셈 적용" + ], + [ + "돌쌓기", + "㎡", + "물량산출", + "막돌을 채취하여 사용" + ], + [ + "돌운반", + "㎡", + "물량산출", + "조견표에 의함" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-18", + "number": "5-18", + "name": "씨뿌리기(줄)", + "level": 2, + "parent_code": "FP-05", + "sort_order": 34304, + "tables": [ + { + "pum_table_id": "F0125", + "section": "5-18. 씨뿌리기(줄)", + "source_line": 2630, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "종자", + "비료", + "비토", + "객토", + "골파기", + "씨덮기", + "특별인부", + "보통인부" + ], + "condition_note": [ + "공정별", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "종자", + "g", + "2", + "" + ], + [ + "비료", + "g", + "29.3", + "" + ], + [ + "비토", + "㎥", + "0.002", + "" + ], + [ + "객토", + "㎥", + "0.0004", + "" + ], + [ + "골파기", + "㎥", + "0.0045", + "" + ], + [ + "씨덮기", + "㎥", + "0.002", + "" + ], + [ + "특별인부", + "인", + "0.00055", + "" + ], + [ + "보통인부", + "인", + "0.00838", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-19", + "number": "5-19", + "name": "표토 절취 및 정지", + "level": 2, + "parent_code": "FP-05", + "sort_order": 34560, + "tables": [] + }, + { + "work_item_code": "FP-05-19-01", + "number": "5-19-1", + "name": "표토절취 및 모으기", + "level": 3, + "parent_code": "FP-05-19", + "sort_order": 34816, + "tables": [ + { + "pum_table_id": "F0126", + "section": "5-19-1. 표토절취 및 모으기", + "source_line": 2653, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "6", + "표토절취" + ], + "condition_note": [ + "구 분", + "직 종", + "취 급 심 도(㎝)" + ], + "raw_row": [ + [ + "6", + "9", + "12", + "15", + "18", + "", + "" + ], + [ + "표토절취", + "보통인부", + "0.20", + "0.17", + "0.16", + "0.15", + "0.14" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-19-02", + "number": "5-19-2", + "name": "표토펴기 및 고르기", + "level": 3, + "parent_code": "FP-05-19", + "sort_order": 35072, + "tables": [ + { + "pum_table_id": "F0127", + "section": "5-19-2. 표토펴기 및 고르기", + "source_line": 2664, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 30.0, + "basis_unit": "㎥", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "15", + "㎥ 당", + "100㎡" + ], + "condition_note": [ + "구 분", + "직 종", + "표 토 두 께(㎝)", + "비고" + ], + "raw_row": [ + [ + "15", + "30", + "", + "", + "" + ], + [ + "㎥ 당", + "보통인부", + "0.14", + "0.11", + "" + ], + [ + "100㎡", + "2.14", + "3.33", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-20", + "number": "5-20", + "name": "표토관리", + "level": 2, + "parent_code": "FP-05", + "sort_order": 35328, + "tables": [ + { + "pum_table_id": "F0128", + "section": "5-20. 표토관리", + "source_line": 2676, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "표토채취", + "굴착기(무한궤도, 0.7㎥)", + "표토붙이기" + ], + "condition_note": [ + "공 정", + "구 분", + "공정량", + "비 고" + ], + "raw_row": [ + [ + "표토채취", + "보통인부", + "0.2인", + "" + ], + [ + "굴착기(무한궤도, 0.7㎥)", + "0.1시간", + "", + "" + ], + [ + "표토붙이기", + "보통인부", + "0.2인", + "" + ], + [ + "굴착기(무한궤도, 0.7㎥)", + "0.1시간", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-21", + "number": "5-21", + "name": "표토이식", + "level": 2, + "parent_code": "FP-05", + "sort_order": 35584, + "tables": [ + { + "pum_table_id": "F0129", + "section": "5-21. 표토이식", + "source_line": 2689, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "채 취", + "운 반", + "붙이기" + ], + "condition_note": [ + "구 분", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "채 취", + "0.34", + "" + ], + [ + "운 반", + "0.26", + "" + ], + [ + "붙이기", + "0.47", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-22", + "number": "5-22", + "name": "평떼", + "level": 2, + "parent_code": "FP-05", + "sort_order": 35840, + "tables": [] + }, + { + "work_item_code": "FP-05-22-01", + "number": "5-22-1", + "name": "떼 구입 및 운반", + "level": 3, + "parent_code": "FP-05-22", + "sort_order": 36096, + "tables": [] + }, + { + "work_item_code": "FP-05-22-02", + "number": "5-22-2", + "name": "평떼 붙임", + "level": 3, + "parent_code": "FP-05-22", + "sort_order": 36352, + "tables": [ + { + "pum_table_id": "F0130", + "section": "5-22-2. 평떼 붙임", + "source_line": 2712, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조경공", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "조경공", + "인", + "0.0099", + "" + ], + [ + "보통인부", + "인", + "0.0231", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-22-03", + "number": "5-22-3", + "name": "떼꽂이 제작 및 설치", + "level": 3, + "parent_code": "FP-05-22", + "sort_order": 36608, + "tables": [ + { + "pum_table_id": "F0131", + "section": "5-22-3. 떼꽂이 제작 및 설치", + "source_line": 2721, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "보통인부", + "인", + "0.011", + "1일1인 1,000개, ㎡당 3.3개 기준" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-22-04", + "number": "5-22-4", + "name": "평떼 시비", + "level": 3, + "parent_code": "FP-05-22", + "sort_order": 36864, + "tables": [ + { + "pum_table_id": "F0132", + "section": "5-22-4. 평떼 시비", + "source_line": 2731, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조경공", + "보통인부", + "트럭" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "수량", + "시공량(㎡)" + ], + "raw_row": [ + [ + "조경공", + "", + "인", + "2", + "22,500" + ], + [ + "보통인부", + "1", + "", + "", + "" + ], + [ + "트럭", + "2.5ton", + "대", + "1", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-23", + "number": "5-23", + "name": "줄떼", + "level": 2, + "parent_code": "FP-05", + "sort_order": 37120, + "tables": [] + }, + { + "work_item_code": "FP-05-23-01", + "number": "5-23-1", + "name": "떼 구입 및 운반", + "level": 3, + "parent_code": "FP-05-23", + "sort_order": 37376, + "tables": [] + }, + { + "work_item_code": "FP-05-23-02", + "number": "5-23-2", + "name": "떼붙임", + "level": 3, + "parent_code": "FP-05-23", + "sort_order": 37632, + "tables": [ + { + "pum_table_id": "F0133", + "section": "5-23-2. 떼붙임", + "source_line": 2752, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조경공", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "조경공", + "인", + "0.0084", + "" + ], + [ + "보통인부", + "인", + "0.0196", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-23-03", + "number": "5-23-3", + "name": "떼꽂이 제작 및 설치", + "level": 3, + "parent_code": "FP-05-23", + "sort_order": 37888, + "tables": [ + { + "pum_table_id": "F0134", + "section": "5-23-3. 떼꽂이 제작 및 설치", + "source_line": 2761, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "보통인부", + "인", + "0.0033", + "1일1인 1,000개, ㎡당 3.3개 기준" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-24", + "number": "5-24", + "name": "씨앗뿜어붙이기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 38144, + "tables": [] + }, + { + "work_item_code": "FP-05-24-01", + "number": "5-24-1", + "name": "뿜어뿥이기-기계/일반", + "level": 3, + "parent_code": "FP-05-24", + "sort_order": 38400, + "tables": [ + { + "pum_table_id": "F0135", + "section": "5-24-1. 뿜어붙이기-기계/일반", + "source_line": 2771, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "비 료", + "피 복 제", + "색 소", + "트 럭", + "물 탱 크", + "보 통 인 부" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "비 료", + "피 복 제", + "침식안정제", + "색 소", + "장비", + "트 럭", + "물 탱 크", + "인력", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "적용수량", + "비 고" + ], + "raw_row": [ + [ + "자재", + "종 자", + "", + "kg", + "0.025", + "" + ], + [ + "비 료", + "복합비료", + "kg", + "0.1", + "", + "" + ], + [ + "피 복 제", + "화이버", + "kg", + "0.18", + "", + "" + ], + [ + "침식안정제", + "합성접착제", + "kg", + "0.1", + "", + "" + ], + [ + "색 소", + "색 소", + "kg", + "0.002", + "", + "" + ], + [ + "장비", + "종자살포기", + "2,500-3,000ℓ", + "시간", + "0.0024", + "" + ], + [ + "트 럭", + "4.5ton", + "시간", + "0.0024", + "", + "" + ], + [ + "물 탱 크", + "5,500ℓ", + "시간", + "0.0036", + "", + "" + ], + [ + "인력", + "조 경 공", + "", + "인", + "0.0007", + "" + ], + [ + "보 통 인 부", + "", + "인", + "0.0004", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-24-02", + "number": "5-24-2", + "name": "뿜어붙이기-기계/마사토", + "level": 3, + "parent_code": "FP-05-24", + "sort_order": 38656, + "tables": [ + { + "pum_table_id": "F0136", + "section": "5-24-2. 뿜어붙이기-기계/마사토", + "source_line": 2796, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "비 료", + "피 복 제", + "색 소", + "트 럭", + "물 탱 크" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "비 료", + "피 복 제", + "침식안정제", + "색 소", + "장비", + "트 럭", + "물 탱 크", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "적용수량", + "비 고" + ], + "raw_row": [ + [ + "자재", + "종 자", + "", + "kg", + "0.025", + "" + ], + [ + "비 료", + "복합비료", + "kg", + "0.1", + "", + "" + ], + [ + "피 복 제", + "화이버", + "kg", + "0.18", + "", + "" + ], + [ + "침식안정제", + "합성접착제", + "kg", + "0.1", + "", + "" + ], + [ + "색 소", + "색 소", + "kg", + "0.002", + "", + "" + ], + [ + "장비", + "종자살포기", + "2,500-3,000ℓ", + "시간", + "0.0024", + "" + ], + [ + "트 럭", + "4.5ton", + "시간", + "0.0024", + "", + "" + ], + [ + "물 탱 크", + "5,500ℓ", + "시간", + "0.0036", + "", + "" + ], + [ + "인력", + "조 경 공", + "뿜어붙이기인력", + "인", + "0.0007", + "" + ], + [ + "보통인부", + "뿜어붙이기인력", + "인", + "0.0004", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0137", + "section": "5-24-2. 뿜어붙이기-기계/마사토", + "source_line": 2814, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "초본 위주형", + "초본, 야생화류", + "외래초종(양잔디류)", + "합 계", + "초본ㆍ관목 혼합형", + "목본 군락형" + ], + "condition_note": [ + "복원목표", + "식 생 구 분", + "종자배합비율(%)" + ], + "raw_row": [ + [ + "초본 위주형", + "관목류", + "20~40" + ], + [ + "초본, 야생화류", + "40~80", + "" + ], + [ + "외래초종(양잔디류)", + "0~10", + "" + ], + [ + "합 계", + "100", + "" + ], + [ + "초본ㆍ관목 혼합형", + "관목류", + "30~50" + ], + [ + "초본, 야생화류", + "45~70", + "" + ], + [ + "외래초종(양잔디류)", + "0~5", + "" + ], + [ + "합 계", + "100", + "" + ], + [ + "목본 군락형", + "교목류, 아교목류, 관목류", + "40~70" + ], + [ + "초본, 야생화류", + "30~70", + "" + ], + [ + "외래초종(양잔디류)", + "0", + "" + ], + [ + "합 계", + "100", + "" + ] + ] + }, + { + "pum_table_id": "F0138", + "section": "5-24-2. 뿜어붙이기-기계/마사토", + "source_line": 2831, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "초본 위주형", + "초본, 야생화류", + "외래초종(양잔디류)", + "합 계", + "초본·관목 혼합형", + "목본 군락형" + ], + "condition_note": [ + "복원목표", + "식 생 구 분", + "종자배합비율(%)" + ], + "raw_row": [ + [ + "초본 위주형", + "관목류", + "10∼40" + ], + [ + "초본, 야생화류", + "40∼80", + "" + ], + [ + "외래초종(양잔디류)", + "10∼20", + "" + ], + [ + "합 계", + "100", + "" + ], + [ + "초본·관목 혼합형", + "관목류, 아교목류", + "30∼50" + ], + [ + "초본, 야생화류", + "40∼70", + "" + ], + [ + "외래초종(양잔디류)", + "5∼15", + "" + ], + [ + "합 계", + "100", + "" + ], + [ + "목본 군락형", + "교목류, 아교목류, 관목류", + "35∼60" + ], + [ + "초본, 야생화류", + "35∼65", + "" + ], + [ + "외래초종(양잔디류)", + "3∼10", + "" + ], + [ + "합 계", + "100", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-25", + "number": "5-25", + "name": "거적덮기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 38912, + "tables": [ + { + "pum_table_id": "F0139", + "section": "5-25. 거적덮기", + "source_line": 2861, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "자재", + "거 적", + "㎡", + "1.1", + "" + ], + [ + "인력", + "조경공", + "인", + "0.002", + "" + ], + [ + "보통인부", + "인", + "0.0007", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-26", + "number": "5-26", + "name": "흙갈이", + "level": 2, + "parent_code": "FP-05", + "sort_order": 39168, + "tables": [] + }, + { + "work_item_code": "FP-05-26-01", + "number": "5-26-1", + "name": "막갈이", + "level": 3, + "parent_code": "FP-05-26", + "sort_order": 39424, + "tables": [ + { + "pum_table_id": "F0140", + "section": "5-26-1. 막갈이", + "source_line": 2874, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "9", + "사토", + "양토", + "식토" + ], + "condition_note": [ + "토성", + "막갈이깊이(cm)" + ], + "raw_row": [ + [ + "9", + "12", + "15", + "18", + "21", + "" + ], + [ + "사토", + "5", + "7", + "9", + "11", + "13" + ], + [ + "양토", + "6", + "8", + "11", + "13", + "15" + ], + [ + "식토", + "8", + "11", + "13", + "15", + "18" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-26-02", + "number": "5-26-2", + "name": "흙부수기", + "level": 3, + "parent_code": "FP-05-26", + "sort_order": 39680, + "tables": [ + { + "pum_table_id": "F0141", + "section": "5-26-2. 흙부수기", + "source_line": 2886, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "9", + "사토", + "양토", + "식토" + ], + "condition_note": [ + "토성", + "막갈이깊이(cm)" + ], + "raw_row": [ + [ + "9", + "12", + "15", + "18", + "21", + "" + ], + [ + "사토", + "3", + "4", + "5", + "6", + "7" + ], + [ + "양토", + "4", + "5", + "6", + "7", + "8" + ], + [ + "식토", + "5", + "6", + "7", + "8", + "9" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-26-03", + "number": "5-26-3", + "name": "돌자갈치우기", + "level": 3, + "parent_code": "FP-05-26", + "sort_order": 39936, + "tables": [ + { + "pum_table_id": "F0142", + "section": "5-26-3. 돌자갈치우기", + "source_line": 2897, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10% 이내", + "개답", + "개전" + ], + "condition_note": [ + "토성", + "경토깊이(cm)" + ], + "raw_row": [ + [ + "10% 이내", + "10∼30%", + "30% 이상", + "" + ], + [ + "개답", + "2", + "6", + "17" + ], + [ + "개전", + "0.5", + "3.5", + "6.5" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-27", + "number": "5-27", + "name": "식재면 관리", + "level": 2, + "parent_code": "FP-05", + "sort_order": 40192, + "tables": [ + { + "pum_table_id": "F0143", + "section": "5-27. 식재면 관리", + "source_line": 2905, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조경공", + "보통인부" + ], + "condition_note": [ + "구 분", + "수량", + "비고" + ], + "raw_row": [ + [ + "조경공", + "0.01", + "" + ], + [ + "보통인부", + "0.08", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-28", + "number": "5-28", + "name": "비탈덮기", + "level": 2, + "parent_code": "FP-05", + "sort_order": 40448, + "tables": [] + }, + { + "work_item_code": "FP-05-28-01", + "number": "5-28-1", + "name": "짚망", + "level": 3, + "parent_code": "FP-05-28", + "sort_order": 40704, + "tables": [ + { + "pum_table_id": "F0144", + "section": "5-28-1. 짚망", + "source_line": 2919, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특 별 인 부", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "특 별 인 부", + "0.19", + "" + ], + [ + "보 통 인 부", + "0.06", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-28-02", + "number": "5-28-2", + "name": "방초매트 및 야자섬유매트 포장", + "level": 3, + "parent_code": "FP-05-28", + "sort_order": 40960, + "tables": [ + { + "pum_table_id": "F0145", + "section": "5-28-2. 방초매트 및 야자섬유매트 포장", + "source_line": 2929, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [ + "조 경 공", + "보 통 인 부" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "폭 1.5m 이하", + "조 경 공", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "단 위", + "수량", + "시 공 량 (㎡)" + ], + "raw_row": [ + [ + "폭 1.5m 이하", + "폭 2.0m 이하", + "", + "", + "" + ], + [ + "조 경 공", + "인", + "2", + "90", + "130" + ], + [ + "보 통 인 부", + "인", + "1", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-29", + "number": "5-29", + "name": "복사이식", + "level": 2, + "parent_code": "FP-05", + "sort_order": 41216, + "tables": [ + { + "pum_table_id": "F0146", + "section": "5-29. 복사이식", + "source_line": 2941, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.3" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "K(버킷계수)", + "f(체적환산계수)", + "E(작업효율)", + "Cm(1회 사이클시간)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K(버킷계수)", + "0.9", + "" + ], + [ + "f(체적환산계수)", + "1/1.3", + "" + ], + [ + "E(작업효율)", + "0.70", + "" + ], + [ + "Cm(1회 사이클시간)", + "20(135°)sec", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-30", + "number": "5-30", + "name": "표시봉 설치", + "level": 2, + "parent_code": "FP-05", + "sort_order": 41472, + "tables": [ + { + "pum_table_id": "F0147", + "section": "5-30. 표시봉설치", + "source_line": 2961, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1000.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "표시봉 설치" + ], + "condition_note": [ + "구 분", + "규 격", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "표시봉 설치", + "길이 1.0m 이상", + "0.70", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-31", + "number": "5-31", + "name": "지주목 설치", + "level": 2, + "parent_code": "FP-05", + "sort_order": 41728, + "tables": [ + { + "pum_table_id": "F0148", + "section": "5-31. 지주목설치", + "source_line": 2973, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1000.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "지주목 설치" + ], + "condition_note": [ + "구 분", + "규 격", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "지주목 설치", + "길이 1.0m 이상", + "1.00", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-32", + "number": "5-32", + "name": "대절작업", + "level": 2, + "parent_code": "FP-05", + "sort_order": 41984, + "tables": [ + { + "pum_table_id": "F0149", + "section": "5-32. 대절작업", + "source_line": 2985, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1000.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "대 절" + ], + "condition_note": [ + "구 분", + "사용도구", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "대 절", + "전정가위", + "0.50", + "특별인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-33", + "number": "5-33", + "name": "묘목 가식작업", + "level": 2, + "parent_code": "FP-05", + "sort_order": 42240, + "tables": [ + { + "pum_table_id": "F0150", + "section": "5-33. 묘목 가식작업", + "source_line": 2996, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1000.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소묘, 중묘", + "대 묘" + ], + "condition_note": [ + "구 분", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "소묘, 중묘", + "0.20", + "보통인부" + ], + [ + "대 묘", + "0.30", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-05-34", + "number": "5-34", + "name": "묘목 소운반", + "level": 2, + "parent_code": "FP-05", + "sort_order": 42496, + "tables": [ + { + "pum_table_id": "F0151", + "section": "5-34. 묘목 소운반", + "source_line": 3007, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1000.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소 묘", + "중 묘", + "대 묘" + ], + "condition_note": [ + "구 분", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "소 묘", + "0.15", + "보통인부" + ], + [ + "중 묘", + "0.20", + "" + ], + [ + "대 묘", + "3.40", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06", + "number": "6", + "name": "생육보조", + "level": 1, + "parent_code": null, + "sort_order": 42752, + "tables": [] + }, + { + "work_item_code": "FP-06-01", + "number": "6-1", + "name": "비료주기", + "level": 2, + "parent_code": "FP-06", + "sort_order": 43008, + "tables": [ + { + "pum_table_id": "F0153", + "section": "6-1. 비료주기", + "source_line": 3062, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인공림(10년이하 조림지)", + "인공림(10년초과 성림지)", + "천연림" + ], + "condition_note": [ + "구 분", + "소요인력 (인/100kg)", + "인력구분" + ], + "raw_row": [ + [ + "인공림(10년이하 조림지)", + "3.5", + "보통인부" + ], + [ + "인공림(10년초과 성림지)", + "4.5", + "" + ], + [ + "천연림", + "4.5", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-02", + "number": "6-2", + "name": "풀베기", + "level": 2, + "parent_code": "FP-06", + "sort_order": 43264, + "tables": [] + }, + { + "work_item_code": "FP-06-02-01", + "number": "6-2-1", + "name": "둘레베기", + "level": 3, + "parent_code": "FP-06-02", + "sort_order": 43520, + "tables": [ + { + "pum_table_id": "F0154", + "section": "6-2-1. 둘레베기", + "source_line": 3079, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "낫" + ], + "condition_note": [ + "사용도구", + "단위", + "소요인력", + "인력구성" + ], + "raw_row": [ + [ + "낫", + "인/100본", + "0.18", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-02-02", + "number": "6-2-2", + "name": "줄베기", + "level": 3, + "parent_code": "FP-06-02", + "sort_order": 43776, + "tables": [ + { + "pum_table_id": "F0155", + "section": "6-2-2. 줄베기", + "source_line": 3089, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "묘목찾기", + "줄 베 기", + "1,500본 이상 3,000본 미만", + "3,000본 이상" + ], + "condition_note": [ + "공 법", + "사용도구", + "단위", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "묘목찾기", + "낫", + "인/100본", + "0.07", + "보통인부", + "" + ], + [ + "줄 베 기", + "1,500본 미만", + "예취기", + "인/ha", + "1.50", + "특별인부" + ], + [ + "1,500본 이상 3,000본 미만", + "예취기", + "인/ha", + "2.00", + "특별인부", + "" + ], + [ + "3,000본 이상", + "예취기", + "인/ha", + "2.50", + "특별인부", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-02-03", + "number": "6-2-3", + "name": "모두베기", + "level": 3, + "parent_code": "FP-06-02", + "sort_order": 44032, + "tables": [ + { + "pum_table_id": "F0156", + "section": "6-2-3. 모두베기", + "source_line": 3105, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "묘목찾기", + "모두베기", + "조림 2년차", + "조림 3년차 이상" + ], + "condition_note": [ + "공 법", + "사용도구", + "단위", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "묘목찾기", + "낫", + "인/100본", + "0.07", + "보통인부", + "" + ], + [ + "모두베기", + "조림 당해 연도 (전년도 추기조림 포함)", + "예취기", + "인/ha", + "3.50", + "특별인부" + ], + [ + "조림 2년차", + "예취기", + "인/ha", + "4.00", + "특별인부", + "" + ], + [ + "조림 3년차 이상", + "예취기", + "인/ha", + "5.00", + "특별인부", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-03", + "number": "6-3", + "name": "맹아제거", + "level": 2, + "parent_code": "FP-06", + "sort_order": 44288, + "tables": [ + { + "pum_table_id": "F0157", + "section": "6-3. 맹아제거", + "source_line": 3122, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "괭이, 도끼 등" + ], + "condition_note": [ + "사용도구", + "단위", + "소요인력", + "인력구성" + ], + "raw_row": [ + [ + "괭이, 도끼 등", + "인/100본", + "0.20", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-04", + "number": "6-4", + "name": "덩굴제거", + "level": 2, + "parent_code": "FP-06", + "sort_order": 44544, + "tables": [] + }, + { + "work_item_code": "FP-06-04-01", + "number": "6-4-1", + "name": "덩굴걷기", + "level": 3, + "parent_code": "FP-06-04", + "sort_order": 44800, + "tables": [ + { + "pum_table_id": "F0158", + "section": "6-4-1. 덩굴걷기", + "source_line": 3143, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "기계작업" + ], + "condition_note": [ + "구 분", + "사용도구", + "단 위", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "기계작업", + "예취기", + "인/ha", + "3.30", + "특별인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-04-02", + "number": "6-4-2", + "name": "덩굴 약제 살포처리", + "level": 3, + "parent_code": "FP-06-04", + "sort_order": 45056, + "tables": [ + { + "pum_table_id": "F0159", + "section": "6-4-2. 덩굴 약제 살포처리", + "source_line": 3153, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "약제살포", + "작업보조" + ], + "condition_note": [ + "구 분", + "사용도구", + "단위", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "약제살포", + "배부식분무기", + "인/ha", + "1.00", + "특별인부" + ], + [ + "작업보조", + "", + "인/ha", + "1.50", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-04-03", + "number": "6-4-3", + "name": "소금처리", + "level": 3, + "parent_code": "FP-06-04", + "sort_order": 45312, + "tables": [ + { + "pum_table_id": "F0160", + "section": "6-4-3. 소금처리", + "source_line": 3164, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "소금 처리", + "2 ~ 6㎝미만", + "6 ~ 8㎝미만", + "8㎝ 이상", + "덩굴 제거지점 표시" + ], + "condition_note": [ + "구 분", + "주두부 직경", + "단위", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "소금 처리", + "2㎝ 미만", + "인/100본", + "0.20", + "보통인부" + ], + [ + "2 ~ 6㎝미만", + "인/100본", + "0.40", + "", + "" + ], + [ + "6 ~ 8㎝미만", + "인/100본", + "0.50", + "", + "" + ], + [ + "8㎝ 이상", + "인/100본", + "0.60", + "", + "" + ], + [ + "덩굴 제거지점 표시", + "인/100본", + "0.05", + "보통인부", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-04-04", + "number": "6-4-4", + "name": "뿌리제거", + "level": 3, + "parent_code": "FP-06-04", + "sort_order": 45568, + "tables": [ + { + "pum_table_id": "F0161", + "section": "6-4-4. 뿌리제거", + "source_line": 3183, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "뿌리 고살", + "1 ~ 4㎝", + "4㎝ 초과", + "덩굴 제거지점 표시" + ], + "condition_note": [ + "구 분", + "주두부 직경", + "단위", + "소요인력", + "인력구분" + ], + "raw_row": [ + [ + "뿌리 고살", + "1㎝ 미만", + "인/100본", + "0.40", + "보통인부" + ], + [ + "1 ~ 4㎝", + "인/100본", + "0.80", + "", + "" + ], + [ + "4㎝ 초과", + "인/100본", + "1.20", + "", + "" + ], + [ + "덩굴 제거지점 표시", + "인/100본", + "0.05", + "보통인부", + "" + ] + ] + }, + { + "pum_table_id": "F0162", + "section": "6-4-4. 뿌리제거", + "source_line": 3234, + "pum_form": "reference", + "form_basis": "헤더 '할인'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "80% 이상", + "60∼80%", + "40∼60%", + "20∼40%", + "20%이하" + ], + "condition_note": [ + "덩굴 피복도(%)", + "할인ㆍ할증율", + "환산계수", + "덩굴본수(본/ha)" + ], + "raw_row": [ + [ + "80% 이상", + "1.50", + "1.00", + "1,600" + ], + [ + "60∼80%", + "1.20", + "0.80", + "1,300" + ], + [ + "40∼60%", + "1.00", + "0.67", + "1,100" + ], + [ + "20∼40%", + "0.80", + "0.53", + "900" + ], + [ + "20%이하", + "0.50", + "0.33", + "500" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-05", + "number": "6-5", + "name": "어린나무가꾸기", + "level": 2, + "parent_code": "FP-06", + "sort_order": 45824, + "tables": [ + { + "pum_table_id": "F0163", + "section": "6-5. 어린나무 가꾸기", + "source_line": 3244, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부 (체인톱 사용)", + "유령림 단계", + "보통인부 (작업 보조)" + ], + "condition_note": [ + "인력구분", + "대상지 유형", + "단위", + "소요인력", + "비고" + ], + "raw_row": [ + [ + "특별인부 (체인톱 사용)", + "치수림 단계", + "인/ha", + "3.00", + "" + ], + [ + "유령림 단계", + "인/ha", + "4.00", + "", + "" + ], + [ + "보통인부 (작업 보조)", + "인/ha", + "2.00", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-06", + "number": "6-6", + "name": "가지치기 및 수형교정", + "level": 2, + "parent_code": "FP-06", + "sort_order": 46080, + "tables": [ + { + "pum_table_id": "F0164", + "section": "6-6. 가지치기 및 수형교정", + "source_line": 3276, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "0~0.5m", + "0.5~1m", + "0~1m", + "0~2m", + "0~4m", + "2~4m", + "4~6m" + ], + "condition_note": [ + "가지치기 높이", + "소나무 낙엽송", + "잣나무,리기다 (기타침엽수)", + "편백", + "활엽수", + "인력구분" + ], + "raw_row": [ + [ + "0~0.5m", + "0.1", + "0.1", + "0.2", + "0.1", + "보통인부" + ], + [ + "0.5~1m", + "0.2", + "0.3", + "0.3", + "0.1", + "" + ], + [ + "0~1m", + "0.3", + "0.4", + "0.5", + "0.2", + "" + ], + [ + "0~2m", + "0.6", + "0.8", + "1.0", + "0.3", + "" + ], + [ + "0~4m", + "1.4", + "1.8", + "2.2", + "0.7", + "" + ], + [ + "2~4m", + "0.8", + "1.0", + "1.2", + "0.4", + "" + ], + [ + "4~6m", + "1.0", + "1.2", + "1.4", + "0.5", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-07", + "number": "6-7", + "name": "토양개량 및 치환", + "level": 2, + "parent_code": "FP-06", + "sort_order": 46336, + "tables": [] + }, + { + "work_item_code": "FP-06-07-01", + "number": "6-7-1", + "name": "교목 시비", + "level": 3, + "parent_code": "FP-06-07", + "sort_order": 46592, + "tables": [ + { + "pum_table_id": "F0165", + "section": "6-7-1. 교목 시비", + "source_line": 3304, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "11미만", + "특별인부", + "보통인부" + ], + "condition_note": [ + "구 분", + "수량(근원직경 ㎝)" + ], + "raw_row": [ + [ + "11미만", + "11∼21 미만", + "21∼31 미만", + "31∼41 미만", + "41∼51 미만", + "" + ], + [ + "특별인부", + "0.29", + "0.37", + "0.44", + "0.51", + "0.58" + ], + [ + "보통인부", + "0.09", + "0.11", + "0.13", + "0.16", + "0.18" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-07-02", + "number": "6-7-2", + "name": "관목 시비", + "level": 3, + "parent_code": "FP-06-07", + "sort_order": 46848, + "tables": [ + { + "pum_table_id": "F0166", + "section": "6-7-2. 관목 시비", + "source_line": 3318, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특 별 인 부", + "보 통 인 부" + ], + "condition_note": [ + "명 칭", + "수 량", + "비고" + ], + "raw_row": [ + [ + "특 별 인 부", + "0.3", + "" + ], + [ + "보 통 인 부", + "0.8", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-07-03", + "number": "6-7-3", + "name": "초본류 시비", + "level": 3, + "parent_code": "FP-06-07", + "sort_order": 47104, + "tables": [ + { + "pum_table_id": "F0167", + "section": "6-7-3. 초본류 시비", + "source_line": 3330, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10000.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "특 별 인 부", + "보 통 인 부" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특 별 인 부", + "보 통 인 부", + "트 럭(2.5t)" + ], + "condition_note": [ + "명 칭", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "특 별 인 부", + "인", + "0.4" + ], + [ + "보 통 인 부", + "인", + "1.4" + ], + [ + "트 럭(2.5t)", + "시 간", + "2.6" + ] + ] + } + ] + }, + { + "work_item_code": "FP-06-08", + "number": "6-8", + "name": "목재칩 포설", + "level": 2, + "parent_code": "FP-06", + "sort_order": 47360, + "tables": [ + { + "pum_table_id": "F0168", + "section": "6-8. 목재칩 포설", + "source_line": 3344, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "우 드 칩", + "보 통 인 부" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "우 드 칩", + "보 통 인 부", + "소 운 반" + ], + "condition_note": [ + "구 분", + "규 격", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "우 드 칩", + "분쇄목 T10cm 기준", + "㎥", + "0.11" + ], + [ + "보 통 인 부", + "-", + "인", + "0.02" + ], + [ + "소 운 반", + "L20m 리어카 기준", + "Ton", + "0.11" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07", + "number": "7", + "name": "집재 및 운재", + "level": 1, + "parent_code": null, + "sort_order": 47616, + "tables": [] + }, + { + "work_item_code": "FP-07-01", + "number": "7-1", + "name": "인력집재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 47872, + "tables": [] + }, + { + "work_item_code": "FP-07-01-01", + "number": "7-1-1", + "name": "수확", + "level": 3, + "parent_code": "FP-07-01", + "sort_order": 48128, + "tables": [ + { + "pum_table_id": "F0169", + "section": "7-1-1. 수확", + "source_line": 3361, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "100m이하", + "200m이하", + "300m이하" + ], + "condition_note": [ + "구 분", + "어려움 (15˚ 미만)", + "중 (30˚ 초과)", + "쉬 움 (15˚~30˚)" + ], + "raw_row": [ + [ + "", + "㎥", + "㎥", + "㎥" + ], + [ + "100m이하", + "1.9", + "3.1", + "4.3" + ], + [ + "200m이하", + "1.4", + "2.3", + "3.3" + ], + [ + "300m이하", + "1.1", + "1.9", + "2.5" + ] + ] + }, + { + "pum_table_id": "F0170", + "section": "7-1-1. 수확", + "source_line": 3371, + "pum_form": "requirement", + "form_basis": "'ha당'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "ha당원목재적 평균거리", + "100m이하", + "200m이하", + "300m이하" + ], + "condition_note": [ + "경사", + "어려움(15˚ 미만)", + "중(30˚ 초과)", + "쉬움(15˚ ~30˚)" + ], + "raw_row": [ + [ + "ha당원목재적 평균거리", + "50㎥미만", + "50㎥이상", + "50㎥미만", + "50㎥이상", + "50㎥미만", + "50㎥이상" + ], + [ + "", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥" + ], + [ + "100m이하", + "1.8", + "2.0", + "2.9", + "3.3", + "4.1", + "4.6" + ], + [ + "200m이하", + "1.3", + "1.5", + "2.1", + "2.4", + "3.0", + "3.4" + ], + [ + "300m이하", + "1.0", + "1.1", + "1.8", + "1.9", + "2.4", + "2.7" + ] + ] + }, + { + "pum_table_id": "F0171", + "section": "7-1-1. 수확", + "source_line": 3381, + "pum_form": "requirement", + "form_basis": "'ha당'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "ha당원목재적 평균거리", + "100m이하", + "200m이하", + "300m이하" + ], + "condition_note": [ + "경사", + "어려움(15˚미만)", + "중(30˚초과)", + "쉬움(15˚~30˚)" + ], + "raw_row": [ + [ + "ha당원목재적 평균거리", + "15㎥ 이하", + "15∼ 30㎥", + "30㎥ 초과", + "15㎥ 미만", + "15∼ 30㎥", + "30㎥ 초과", + "15㎥ 이하", + "15∼ 30㎥", + "30㎥ 초과" + ], + [ + "", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥", + "㎥" + ], + [ + "100m이하", + "1.3", + "1.5", + "1.8", + "2.1", + "2.5", + "2.9", + "2.9", + "3.5", + "4.1" + ], + [ + "200m이하", + "1.0", + "1.1", + "1.8", + "1.5", + "1.8", + "2.1", + "2.1", + "2.5", + "2.9" + ], + [ + "300m이하", + "0.8", + "0.9", + "1.0", + "1.3", + "1.5", + "1.7", + "1.7", + "2.1", + "2.3" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-01-02", + "number": "7-1-2", + "name": "숲가꾸기, 병해충방제", + "level": 3, + "parent_code": "FP-07-01", + "sort_order": 48384, + "tables": [ + { + "pum_table_id": "F0172", + "section": "7-1-2. 숲가꾸기, 병해충방제", + "source_line": 3395, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "8", + "10m이하", + "병해충방제", + "20m이하", + "30m이하", + "40m이하", + "50m이하" + ], + "condition_note": [ + "집 재 거 리", + "구분", + "간벌재의 직경(cm)", + "적용인부" + ], + "raw_row": [ + [ + "8", + "10", + "12", + "14", + "16", + "18", + "20", + "22이상", + "", + "", + "" + ], + [ + "10m이하", + "숲가꾸기", + "2.02", + "2.84", + "3.51", + "4.08", + "4.82", + "5.81", + "6.95", + "", + "보통인부" + ], + [ + "병해충방제", + "2.65", + "4.00", + "5.00", + "5.70", + "6.65", + "8.00", + "9.50", + "11.00", + "", + "" + ], + [ + "20m이하", + "숲가꾸기", + "1.40", + "1.89", + "2.32", + "2.76", + "3.36", + "3.88", + "4.53", + "", + "" + ], + [ + "병해충방제", + "1.75", + "2.50", + "3.10", + "3.65", + "4.45", + "5.00", + "5.70", + "6.35", + "", + "" + ], + [ + "30m이하", + "숲가꾸기", + "1.10", + "1.49", + "1.84", + "2.27", + "2.71", + "3.06", + "3.51", + "", + "" + ], + [ + "병해충방제", + "1.40", + "2.00", + "2.50", + "3.10", + "3.65", + "4.00", + "4.45", + "5.05", + "", + "" + ], + [ + "40m이하", + "숲가꾸기", + "0.88", + "1.18", + "1.45", + "1.72", + "2.09", + "2.36", + "2.80", + "", + "" + ], + [ + "병해충방제", + "1.15", + "1.60", + "2.00", + "2.35", + "2.85", + "3.10", + "3.65", + "4.25", + "", + "" + ], + [ + "50m이하", + "숲가꾸기", + "0.74", + "0.99", + "1.22", + "1.47", + "1.76", + "1.94", + "2.30", + "", + "" + ], + [ + "병해충방제", + "1.00", + "1.40", + "1.75", + "2.10", + "2.50", + "2.65", + "3.10", + "3.70", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-02", + "number": "7-2", + "name": "수라집재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 48640, + "tables": [ + { + "pum_table_id": "F0173", + "section": "7-2. 수라집재", + "source_line": 3419, + "pum_form": "productivity", + "form_basis": "헤더 'ha당 집재재적'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "20이하㎥", + "0~100m", + "0~150m", + "0~200m", + "0~250m" + ], + "condition_note": [ + "집재거리", + "ha당 집재재적", + "소요인력" + ], + "raw_row": [ + [ + "20이하㎥", + "21~30㎥", + "31~40㎥", + "41~50㎥", + "50초과㎥", + "", + "" + ], + [ + "0~100m", + "1.94", + "2.20", + "2.39", + "2.56", + "2.70", + "특별인부" + ], + [ + "0~150m", + "1.64", + "1.91", + "2.12", + "2.29", + "2.45", + "" + ], + [ + "0~200m", + "1.42", + "1.68", + "1.89", + "2.07", + "2.23", + "" + ], + [ + "0~250m", + "1.24", + "1.50", + "1.70", + "1.88", + "2.04", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-03", + "number": "7-3", + "name": "아키야윈치(임업용 윈치)", + "level": 2, + "parent_code": "FP-07", + "sort_order": 48896, + "tables": [ + { + "pum_table_id": "F0174", + "section": "7-3. 아키야윈치(임업용 윈치) 집재", + "source_line": 3439, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "50m 내외" + ], + "condition_note": [ + "집재거리", + "수집량", + "소요인력" + ], + "raw_row": [ + [ + "50m 내외", + "8", + "2인 1조(특별인부 1인, 보통인부 1인)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-04", + "number": "7-4", + "name": "소형가선 집재(2드럼 윈치)", + "level": 2, + "parent_code": "FP-07", + "sort_order": 49152, + "tables": [] + }, + { + "work_item_code": "FP-07-04-01", + "number": "7-4-1", + "name": "수확", + "level": 3, + "parent_code": "FP-07-04", + "sort_order": 49408, + "tables": [ + { + "pum_table_id": "F0175", + "section": "7-4-1. 수확", + "source_line": 3452, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "50이하", + "0.1", + "0.2", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "0.9", + "1.0" + ], + "condition_note": [ + "본당재적 (㎥)", + "집재거리(m)", + "인력구분" + ], + "raw_row": [ + [ + "50이하", + "51-100", + "101-150", + "151이상", + "", + "" + ], + [ + "0.1", + "23.32", + "16.13", + "12.33", + "10.99", + "3인1조 (건설기계운전기사1명 특별인부1명 보통인부1명)" + ], + [ + "0.2", + "36.60", + "25.47", + "19.53", + "17.42", + "" + ], + [ + "0.3", + "43.10", + "30.17", + "23.20", + "20.72", + "" + ], + [ + "0.4", + "45.83", + "32.26", + "24.89", + "22.24", + "" + ], + [ + "0.5", + "47.60", + "33.68", + "26.06", + "23.32", + "" + ], + [ + "0.6", + "51.01", + "36.28", + "28.15", + "25.22", + "" + ], + [ + "0.7", + "58.48", + "41.80", + "32.53", + "29.16", + "" + ], + [ + "0.8", + "65.69", + "47.19", + "36.82", + "33.04", + "" + ], + [ + "0.9", + "72.66", + "52.44", + "41.03", + "36.86", + "" + ], + [ + "1.0", + "79.39", + "57.57", + "45.16", + "40.61", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-04-02", + "number": "7-4-2", + "name": "숲가꾸기, 병해충방제", + "level": 3, + "parent_code": "FP-07-04", + "sort_order": 49664, + "tables": [ + { + "pum_table_id": "F0176", + "section": "7-4-2. 숲가꾸기, 산림병해충방제", + "source_line": 3476, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "20m이하", + "집재량" + ], + "condition_note": [ + "구 분", + "집 재 거 리", + "소요인력" + ], + "raw_row": [ + [ + "20m이하", + "40m이하", + "60m이하", + "80m이하", + "100m이하", + "", + "" + ], + [ + "집재량", + "11.19", + "10.54", + "9.95", + "9.44", + "8.96", + "3인 1조 (특별인부 1인, 보통인부 2인)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-05", + "number": "7-5", + "name": "트랙터부착형 집재(지면끌기)", + "level": 2, + "parent_code": "FP-07", + "sort_order": 49920, + "tables": [] + }, + { + "work_item_code": "FP-07-05-01", + "number": "7-5-1", + "name": "수확", + "level": 3, + "parent_code": "FP-07-05", + "sort_order": 50176, + "tables": [ + { + "pum_table_id": "F0177", + "section": "7-5-1. 수확", + "source_line": 3496, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "30이하", + "0.1", + "0.2", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "0.9", + "1.0" + ], + "condition_note": [ + "본당재적 (㎥)", + "집재거리(m)", + "인력구분" + ], + "raw_row": [ + [ + "30이하", + "31-50", + "51-70", + "71이상", + "", + "" + ], + [ + "0.1", + "25.95", + "18.46", + "15.00", + "13.60", + "3인1조 (건설기계운전기사1명 특별인부1명 보통인부1명)" + ], + [ + "0.2", + "40.63", + "29.09", + "23.70", + "21.51", + "" + ], + [ + "0.3", + "47.75", + "34.39", + "28.10", + "25.53", + "" + ], + [ + "0.4", + "50.68", + "36.71", + "30.07", + "27.35", + "" + ], + [ + "0.5", + "52.54", + "38.26", + "31.43", + "28.62", + "" + ], + [ + "0.6", + "56.20", + "41.14", + "33.88", + "30.88", + "" + ], + [ + "0.7", + "64.31", + "47.32", + "39.07", + "35.65", + "" + ], + [ + "0.8", + "72.11", + "53.33", + "44.14", + "40.31", + "" + ], + [ + "0.9", + "79.63", + "59.18", + "49.09", + "44.88", + "" + ], + [ + "1.0", + "86.88", + "64.86", + "53.93", + "49.36", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-05-02", + "number": "7-5-2", + "name": "숲가꾸기, 병해충방제", + "level": 3, + "parent_code": "FP-07-05", + "sort_order": 50432, + "tables": [ + { + "pum_table_id": "F0178", + "section": "7-5-2. 숲가꾸기, 병해충방제", + "source_line": 3519, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "0.1∼0.2㎥", + "0∼40m", + "0∼60m", + "0∼80m", + "0∼100m", + "0∼120m" + ], + "condition_note": [ + "집재거리", + "본당 평균재적", + "인력구분" + ], + "raw_row": [ + [ + "0.1∼0.2㎥", + "0.3∼0.4㎥", + "0.5㎥ 이상", + "", + "" + ], + [ + "0∼40m", + "20.07", + "20.69", + "21.33", + "3인 1조 (건설기계운전기사 1명, 특별인부 1명,보통인부 1명)" + ], + [ + "0∼60m", + "17.61", + "18.15", + "18.71", + "" + ], + [ + "0∼80m", + "15.45", + "15.92", + "16.42", + "" + ], + [ + "0∼100m", + "13.55", + "13.97", + "14.40", + "" + ], + [ + "0∼120m", + "10.96", + "11.87", + "12.78", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-06", + "number": "7-6", + "name": "스윙야더 집재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 50688, + "tables": [ + { + "pum_table_id": "F0179", + "section": "7-6. 스윙야더 집재", + "source_line": 3542, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "60m 이하", + "26㎥" + ], + "condition_note": [ + "집재거리(m)", + "인력구분" + ], + "raw_row": [ + [ + "60m 이하", + "90m 이하", + "" + ], + [ + "26㎥", + "17㎥", + "3인1조 (건설기계운전기사 1명 특별인부 1명, 보통인부 1명)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-07", + "number": "7-7", + "name": "HAM200 집재(춘천․스마트)", + "level": 2, + "parent_code": "FP-07", + "sort_order": 50944, + "tables": [] + }, + { + "work_item_code": "FP-07-07-01", + "number": "7-7-1", + "name": "수확", + "level": 3, + "parent_code": "FP-07-07", + "sort_order": 51200, + "tables": [ + { + "pum_table_id": "F0180", + "section": "7-7-1. 수확", + "source_line": 3556, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "50이하", + "0.1", + "0.2", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "0.9", + "1.0" + ], + "condition_note": [ + "본당재적 (㎥)", + "집재거리(m)", + "인력구분" + ], + "raw_row": [ + [ + "50이하", + "51-100", + "101-150", + "151이상", + "", + "" + ], + [ + "0.1", + "23.32", + "16.13", + "12.33", + "10.99", + "3인1조 (건설기계운전기사1명 특별인부1명 보통인부1명)" + ], + [ + "0.2", + "36.60", + "25.47", + "19.53", + "17.42", + "" + ], + [ + "0.3", + "43.10", + "30.17", + "23.20", + "20.72", + "" + ], + [ + "0.4", + "45.83", + "32.26", + "24.89", + "22.24", + "" + ], + [ + "0.5", + "47.60", + "33.68", + "26.06", + "23.32", + "" + ], + [ + "0.6", + "51.01", + "36.28", + "28.15", + "25.22", + "" + ], + [ + "0.7", + "58.48", + "41.80", + "32.53", + "29.16", + "" + ], + [ + "0.8", + "65.69", + "47.19", + "36.82", + "33.04", + "" + ], + [ + "0.9", + "72.66", + "52.44", + "41.03", + "36.86", + "" + ], + [ + "1.0", + "79.39", + "57.57", + "45.16", + "40.61", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-07-02", + "number": "7-7-2", + "name": "숲가꾸기, 병해충방제", + "level": 3, + "parent_code": "FP-07-07", + "sort_order": 51456, + "tables": [ + { + "pum_table_id": "F0181", + "section": "7-7-2. 숲가꾸기, 병해충방제", + "source_line": 3580, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "100m 이하", + "0∼20㎥", + "21∼40㎥", + "41∼60㎥", + "61∼80㎥", + "81∼100㎥" + ], + "condition_note": [ + "집재재적 (㎥/ha)", + "집재거리", + "소요인력" + ], + "raw_row": [ + [ + "100m 이하", + "150m 이하", + "200m 이하", + "", + "" + ], + [ + "0∼20㎥", + "11.55", + "10.30", + "9.51", + "3인 1조 (건설기계운전기사 1명, 특별인부 1명, 보통인부 1명)" + ], + [ + "21∼40㎥", + "13.11", + "11.49", + "10.53", + "" + ], + [ + "41∼60㎥", + "13.59", + "11.87", + "10.86", + "" + ], + [ + "61∼80㎥", + "13.83", + "12.06", + "11.02", + "" + ], + [ + "81∼100㎥", + "13.97", + "12.17", + "11.12", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-08", + "number": "7-8", + "name": "타워야더(RME 300T)", + "level": 2, + "parent_code": "FP-07", + "sort_order": 51712, + "tables": [] + }, + { + "work_item_code": "FP-07-08-01", + "number": "7-8-1", + "name": "가선설치", + "level": 3, + "parent_code": "FP-07-08", + "sort_order": 51968, + "tables": [ + { + "pum_table_id": "F0182", + "section": "7-8-1. 가선설치", + "source_line": 3607, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "120m 이하", + "상향집재", + "하향집재" + ], + "condition_note": [ + "집재방식", + "작업로(가선)의 길이", + "소요인력" + ], + "raw_row": [ + [ + "120m 이하", + "160m 이하", + "200m 이하", + "240m 이하", + "280m 이하", + "280m 초과", + "", + "" + ], + [ + "상향집재", + "0.6", + "0.6", + "0.8", + "0.9", + "0.9", + "1.1", + "3인 1조 (특별인부 2명 보통인부 1명)" + ], + [ + "하향집재", + "0.7", + "0.8", + "0.9", + "1.1", + "1.1", + "1.4", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-08-02", + "number": "7-8-2", + "name": "가선해체", + "level": 3, + "parent_code": "FP-07-08", + "sort_order": 52224, + "tables": [ + { + "pum_table_id": "F0183", + "section": "7-8-2. 가선해체", + "source_line": 3619, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "120m 이하", + "상향집재", + "하향집재" + ], + "condition_note": [ + "집재방식", + "작업로(가선)의 길이", + "소요인력" + ], + "raw_row": [ + [ + "120m 이하", + "160m 이하", + "200m 이하", + "240m 이하", + "280m 이하", + "280m 초과", + "", + "" + ], + [ + "상향집재", + "0.3", + "0.3", + "0.4", + "0.5", + "0.5", + "0.6", + "3인 1조 (특별인부 2명 보통인부 1명)" + ], + [ + "하향집재", + "0.4", + "0.4", + "0.5", + "0.6", + "0.6", + "0.7", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-08-03", + "number": "7-8-3", + "name": "집재 소요인력", + "level": 3, + "parent_code": "FP-07-08", + "sort_order": 52480, + "tables": [ + { + "pum_table_id": "F0184", + "section": "7-8-3. 집재 소요인력", + "source_line": 3631, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "100m이하", + "0.1", + "0.2", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "0.9", + "1.0" + ], + "condition_note": [ + "본당재적 (㎥)", + "집재거리(m)", + "인력구분" + ], + "raw_row": [ + [ + "100m이하", + "150m이하", + "200m이하", + "250m이하", + "", + "" + ], + [ + "0.1", + "19.20", + "17.45", + "16.00", + "14.77", + "4인1조 (건설기계운전기사1명 특별인부2명 보통인부1명)" + ], + [ + "0.2", + "30.24", + "27.53", + "25.26", + "23.34", + "" + ], + [ + "0.3", + "35.72", + "32.57", + "29.92", + "27.68", + "" + ], + [ + "0.4", + "38.11", + "34.79", + "32.00", + "29.63", + "" + ], + [ + "0.5", + "39.70", + "36.29", + "33.42", + "30.97", + "" + ], + [ + "0.6", + "42.67", + "39.05", + "36.00", + "33.39", + "" + ], + [ + "0.7", + "49.05", + "44.95", + "41.48", + "38.51", + "" + ], + [ + "0.8", + "55.25", + "50.69", + "46.83", + "43.51", + "" + ], + [ + "0.9", + "61.28", + "56.29", + "52.05", + "48.40", + "" + ], + [ + "1.0", + "67.13", + "61.74", + "57.14", + "53.19", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-09", + "number": "7-9", + "name": "부착형 타워(K-301, HAM300) 집재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 52736, + "tables": [] + }, + { + "work_item_code": "FP-07-09-01", + "number": "7-9-1", + "name": "수확", + "level": 3, + "parent_code": "FP-07-09", + "sort_order": 52992, + "tables": [ + { + "pum_table_id": "F0185", + "section": "7-9-1. 수확", + "source_line": 3656, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "100m 이하", + "0~20㎥", + "21~40㎥", + "41~60㎥", + "61~80㎥", + "81~100㎥" + ], + "condition_note": [ + "집재재적 (㎥/ha)", + "집재거리", + "소요인력" + ], + "raw_row": [ + [ + "100m 이하", + "150m 이하", + "200m 이하", + "250m 이하", + "", + "" + ], + [ + "0~20㎥", + "18.76", + "17.53", + "17.11", + "16.70", + "4인1조 (건설기계운전기사 1명 특별인부 2명 보통인부 1명)" + ], + [ + "21~40㎥", + "25.37", + "23.16", + "22.44", + "21.74", + "" + ], + [ + "41~60㎥", + "28.75", + "25.95", + "25.04", + "24.17", + "" + ], + [ + "61~80㎥", + "30.80", + "27.60", + "26.58", + "25.60", + "" + ], + [ + "81~100㎥", + "32.18", + "28.70", + "27.60", + "26.54", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-09-02", + "number": "7-9-2", + "name": "숲가꾸기, 소나무재선충병방제", + "level": 3, + "parent_code": "FP-07-09", + "sort_order": 53248, + "tables": [ + { + "pum_table_id": "F0186", + "section": "7-9-2. 숲가꾸기, 소나무재선충병방제", + "source_line": 3673, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "100m 이하", + "0~20㎥", + "21~40㎥", + "41~60㎥", + "61~80㎥", + "81~100㎥" + ], + "condition_note": [ + "집재재적 (㎥/ha)", + "집재거리", + "소요인력" + ], + "raw_row": [ + [ + "100m 이하", + "150m 이하", + "200m 이하", + "250m 이하", + "", + "" + ], + [ + "0~20㎥", + "17.14", + "16.02", + "14.66", + "13.54", + "4인1조 (건설기계운전기사 1명 특별인부 2명 보통인부 1명)" + ], + [ + "21~40㎥", + "20.56", + "19.18", + "17.53", + "16.17", + "" + ], + [ + "41~60㎥", + "22.09", + "20.60", + "18.82", + "17.37", + "" + ], + [ + "61~80㎥", + "22.96", + "21.40", + "19.55", + "18.05", + "" + ], + [ + "81~100㎥", + "23.51", + "21.91", + "20.02", + "18.48", + "" + ] + ] + }, + { + "pum_table_id": "F0187", + "section": "7-9-2. 숲가꾸기, 소나무재선충병방제", + "source_line": 3692, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "100m 이하", + "0~20㎥", + "21~40㎥", + "41~60㎥", + "61~80㎥", + "81~100㎥" + ], + "condition_note": [ + "노선별 집재재적(㎥/ha)", + "집재거리" + ], + "raw_row": [ + [ + "100m 이하", + "150m 이하", + "200m 이하", + "250m 이하", + "" + ], + [ + "0~20㎥", + "17", + "19", + "23", + "27" + ], + [ + "21~40㎥", + "23", + "25", + "30", + "34" + ], + [ + "41~60㎥", + "26", + "28", + "33", + "37" + ], + [ + "61~80㎥", + "27", + "30", + "35", + "39" + ], + [ + "81~100㎥", + "28", + "31", + "36", + "40" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-10", + "number": "7-10", + "name": "굴착기 우드그랩 산지집재-병해충방제", + "level": 2, + "parent_code": "FP-07", + "sort_order": 53504, + "tables": [ + { + "pum_table_id": "F0188", + "section": "7-10. 굴착기 우드그랩 산지집재-병해충방제", + "source_line": 3707, + "pum_form": "productivity", + "form_basis": "헤더 'ha당 평균 작업량'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "20㎥ 미만", + "작업량" + ], + "condition_note": [ + "구 분", + "ha당 평균 작업량", + "적용인부" + ], + "raw_row": [ + [ + "20㎥ 미만", + "20∼40㎥", + "40㎥ 초과", + "", + "" + ], + [ + "작업량", + "27.49", + "33.77", + "40.89", + "건설기계운전기사 1명 보통인부 1인" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-11", + "number": "7-11", + "name": "동력상하차기(우드그래풀) 집재-수확", + "level": 2, + "parent_code": "FP-07", + "sort_order": 53760, + "tables": [ + { + "pum_table_id": "F0189", + "section": "7-11. 동력상하차기(우드그래플) 집재-수확", + "source_line": 3723, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "원목의 길이", + "1.2m", + "41∼60", + "61∼80", + "81이상" + ], + "condition_note": [ + "집 재 재 적 (㎥/ha)", + "최대집재거리 : 40m 이하", + "소요 인력" + ], + "raw_row": [ + [ + "원목의 길이", + "", + "", + "", + "", + "", + "" + ], + [ + "1.2m", + "1.8m", + "2.1m", + "2.7m", + "3.6m", + "", + "" + ], + [ + "41∼60", + "22.03", + "29.13", + "32.09", + "37.11", + "39.98", + "건설기계 운전기사 1인" + ], + [ + "61∼80", + "24.17", + "32.47", + "35.99", + "42.09", + "45.65", + "" + ], + [ + "81이상", + "25.68", + "34.89", + "38.87", + "45.85", + "49.97", + "" + ] + ] + }, + { + "pum_table_id": "F0190", + "section": "7-11. 동력상하차기(우드그래플) 집재-수확", + "source_line": 3731, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "원목의 길이", + "1.2m", + "41∼60", + "61∼80", + "81이상" + ], + "condition_note": [ + "집 재 재 적 (㎥/ha)", + "최대집재거리 : 80m 이하", + "소요 인력" + ], + "raw_row": [ + [ + "원목의 길이", + "", + "", + "", + "", + "", + "" + ], + [ + "1.2m", + "1.8m", + "2.1m", + "2.7m", + "3.6m", + "", + "" + ], + [ + "41∼60", + "16.22", + "22.10", + "24.65", + "29.13", + "31.78", + "건설기계 운전기사 1인" + ], + [ + "61∼80", + "17.38", + "24.01", + "26.94", + "32.19", + "35.36", + "" + ], + [ + "81이상", + "18.16", + "25.34", + "28.56", + "34.40", + "37.96", + "" + ] + ] + }, + { + "pum_table_id": "F0191", + "section": "7-11. 동력상하차기(우드그래플) 집재-수확", + "source_line": 3745, + "pum_form": "requirement", + "form_basis": "'소요인력'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "원목집재와 동시에 부산물 수집시" + ], + "condition_note": [ + "구 분", + "소요인력" + ], + "raw_row": [ + [ + "원목집재와 동시에 부산물 수집시", + "0.03", + "건설기계 운전기사 1인" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-12", + "number": "7-12", + "name": "동력상하차기(우드그래풀) 집적", + "level": 2, + "parent_code": "FP-07", + "sort_order": 54016, + "tables": [ + { + "pum_table_id": "F0192", + "section": "7-12. 동력상하차기(우드그래플) 집적", + "source_line": 3763, + "pum_form": "requirement", + "form_basis": "'소요인력'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "9㎝ 이하", + "1.8m", + "2.1m", + "2.7m", + "3.6m" + ], + "condition_note": [ + "원목(생산재)의 길이", + "원목의 직경(말구 평균직경)", + "소요인력" + ], + "raw_row": [ + [ + "9㎝ 이하", + "10∼15㎝", + "16∼20㎝", + "21∼25㎝", + "26∼30㎝", + "30㎝초과", + "", + "" + ], + [ + "1.8m", + "25.39", + "28.21", + "31.35", + "34.48", + "37.93", + "41.73", + "1인 1조 (건설기계운전기사 1명)" + ], + [ + "2.1m", + "27.17", + "30.19", + "33.55", + "36.89", + "40.59", + "44.65", + "" + ], + [ + "2.7m", + "29.42", + "32.63", + "36.32", + "39.94", + "43.94", + "48.33", + "" + ], + [ + "3.6m", + "33.44", + "37.16", + "41.29", + "45.41", + "49.96", + "54.95", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-13", + "number": "7-13", + "name": "검척", + "level": 2, + "parent_code": "FP-07", + "sort_order": 54272, + "tables": [ + { + "pum_table_id": "F0193", + "section": "7-13. 검척", + "source_line": 3784, + "pum_form": "requirement", + "form_basis": "'소요인력'", + "basis_quantity": 100.0, + "basis_unit": "㎥", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "생산목 검척" + ], + "condition_note": [ + "구 분", + "소요인력(인/100㎥당)", + "인력구분" + ], + "raw_row": [ + [ + "생산목 검척", + "0.67", + "초급기술자" + ] + ] + }, + { + "pum_table_id": "F0194", + "section": "7-13. 검척", + "source_line": 3834, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "2.1m", + "8", + "10", + "12", + "14", + "16", + "18", + "20", + "22", + "합계" + ], + "condition_note": [ + "재장", + "말구직경", + "낙엽송(개수)", + "경급×개수" + ], + "raw_row": [ + [ + "2.1m", + "6", + "2", + "12" + ], + [ + "8", + "11", + "88", + "" + ], + [ + "10", + "33", + "330", + "" + ], + [ + "12", + "350", + "4,200", + "" + ], + [ + "14", + "200", + "2,800", + "" + ], + [ + "16", + "100", + "1,600", + "" + ], + [ + "18", + "261", + "4,698", + "" + ], + [ + "20", + "153", + "3,060", + "" + ], + [ + "22", + "66", + "1,452", + "" + ], + [ + "합계", + "1,176", + "18,240", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-14", + "number": "7-14", + "name": "원목 운반-수확", + "level": 2, + "parent_code": "FP-07", + "sort_order": 54528, + "tables": [ + { + "pum_table_id": "F0195", + "section": "7-14. 원목 운반-수확", + "source_line": 3849, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "1회 적 재 량", + "용 재 (원목)", + "침 엽 수 (포플러과 포함)", + "저 나 르 기 트 럭 집 재 뗏 목 토 장 집 재" + ], + "condition_note": [ + "종 별", + "공 정" + ], + "raw_row": [ + [ + "1회 적 재 량", + "1인 1일공정", + "", + "", + "", + "", + "", + "" + ], + [ + "용 재 (원목)", + "신 재 (활 잡)", + "목 탄", + "운 반", + "상 하 차", + "", + "", + "" + ], + [ + "침 엽 수 (포플러과 포함)", + "활 엽 수 (포플러과 제외)", + "침", + "활", + "", + "", + "", + "" + ], + [ + "저 나 르 기 트 럭 집 재 뗏 목 토 장 집 재", + "㎥ 0.06∼0.07 4.8∼6.0", + "㎥ 0.05 4.0∼6.0", + "㎥ 0.05 4.0∼5.0", + "kg 40∼60 3,000∼4,000", + "연km 16∼24 3∼5㎥ 20∼30㎥ 5∼10㎥", + "㎥ 5", + "㎥ 4" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-15", + "number": "7-15", + "name": "초소형 포워더 운재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 54784, + "tables": [] + }, + { + "work_item_code": "FP-07-15-01", + "number": "7-15-1", + "name": "수확", + "level": 3, + "parent_code": "FP-07-15", + "sort_order": 55040, + "tables": [ + { + "pum_table_id": "F0196", + "section": "7-15-1. 수확", + "source_line": 3864, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "100", + "회수", + "운반량" + ], + "condition_note": [ + "구분", + "주행거리(m이하)", + "인력구분" + ], + "raw_row": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "1200", + "", + "" + ], + [ + "회수", + "10.4", + "9.97", + "9.51", + "9.06", + "8.60", + "8.14", + "7.69", + "7.22", + "6.77", + "6.31", + "5.85", + "건설기계운전기사1명" + ], + [ + "운반량", + "33.78", + "32.3", + "30.82", + "29.34", + "27.86", + "26.38", + "24.90", + "23.42", + "21.94", + "20.46", + "18.98", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-15-02", + "number": "7-15-2", + "name": "숲가꾸기, 병해충방제", + "level": 3, + "parent_code": "FP-07-15", + "sort_order": 55296, + "tables": [ + { + "pum_table_id": "F0197", + "section": "7-15-2 숲가꾸기, 병해충방제", + "source_line": 3878, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "0.5", + "작업회수", + "운재량" + ], + "condition_note": [ + "구 분", + "운반거리(㎞ 이하)", + "인력구분" + ], + "raw_row": [ + [ + "0.5", + "1.0", + "1.5", + "2.0", + "2.5", + "3.0", + "3.5", + "4.0", + "4.5", + "5.0", + "", + "" + ], + [ + "작업회수", + "8.6", + "6.3", + "5.0", + "4.1", + "3.5", + "3.1", + "2.7", + "2.4", + "2.2", + "2.0", + "건설기계운전기사 1인" + ], + [ + "운재량", + "27.86", + "20.46", + "16.17", + "13.37", + "11.39", + "9.93", + "8.79", + "7.89", + "7.16", + "6.55", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-16", + "number": "7-16", + "name": "소형 운재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 55552, + "tables": [ + { + "pum_table_id": "F0198", + "section": "7-16. 소형 포워더 운재", + "source_line": 3898, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "100", + "회수", + "운반량" + ], + "condition_note": [ + "구분", + "주행거리(m이하)", + "인력구분" + ], + "raw_row": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "1200", + "", + "" + ], + [ + "회수", + "22.97", + "19.20", + "16.50", + "14.46", + "12.87", + "11.60", + "10.55", + "9.68", + "8.94", + "8.31", + "7.28", + "건설기계운전기사 1명" + ], + [ + "운반량", + "168.59", + "140.95", + "121.10", + "106.15", + "94.48", + "85.13", + "77.46", + "71.06", + "65.63", + "60.98", + "53.40", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-17", + "number": "7-17", + "name": "소형트럭 운재", + "level": 2, + "parent_code": "FP-07", + "sort_order": 55808, + "tables": [ + { + "pum_table_id": "F0199", + "section": "7-17. 소형트럭 운재", + "source_line": 3912, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "100", + "회수", + "운반량" + ], + "condition_note": [ + "구분", + "운반거리(m이하)", + "인력구분" + ], + "raw_row": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "", + "" + ], + [ + "회수", + "23.8", + "20.5", + "18.0", + "16.1", + "14.5", + "13.3", + "12.2", + "11.3", + "10.5", + "9.8", + "건설기계 운전기사 1명" + ], + [ + "운반량", + "52.28", + "45.13", + "39.70", + "35.44", + "32.00", + "29.17", + "26.80", + "24.79", + "23.06", + "21.55", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-07-18", + "number": "7-18", + "name": "지조운반(포워더 활용)", + "level": 2, + "parent_code": "FP-07", + "sort_order": 56064, + "tables": [ + { + "pum_table_id": "F0200", + "section": "7-18. 지조운반(포워더 활용)", + "source_line": 3926, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "100", + "회 수", + "운반량" + ], + "condition_note": [ + "구 분", + "운반거리(m이하)", + "인력구분" + ], + "raw_row": [ + [ + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1000", + "", + "" + ], + [ + "회 수", + "22.9", + "17.1", + "13.7", + "11.4", + "9.8", + "8.6", + "7.6", + "6.9", + "6.2", + "5.7", + "건설기계 운전기사 1명" + ], + [ + "운반량", + "91.4", + "68.6", + "54.9", + "45.7", + "39.2", + "34.3", + "30.5", + "27.4", + "24.9", + "22.8", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08", + "number": "8", + "name": "방제", + "level": 1, + "parent_code": null, + "sort_order": 56320, + "tables": [] + }, + { + "work_item_code": "FP-08-01", + "number": "8-1", + "name": "나무주사", + "level": 2, + "parent_code": "FP-08", + "sort_order": 56576, + "tables": [] + }, + { + "work_item_code": "FP-08-01-01", + "number": "8-1-1", + "name": "약제주입기", + "level": 3, + "parent_code": "FP-08-01", + "sort_order": 56832, + "tables": [ + { + "pum_table_id": "F0201", + "section": "8-1-1. 약제주입기", + "source_line": 3945, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "600개 이하", + "1,300개 이하", + "2,000개 이하", + "2,700개 이하", + "3,400개 이하", + "4,100개 이하" + ], + "condition_note": [ + "천공수", + "소요인력(ha당)", + "천공수", + "소요인력(ha당)" + ], + "raw_row": [ + [ + "특별인부", + "보통인부", + "특별인부", + "보통인부", + "", + "" + ], + [ + "600개 이하", + "0.62", + "1.86", + "4,800개 이하", + "1.30", + "3.60" + ], + [ + "1,300개 이하", + "0.76", + "2.28", + "5,500개 이하", + "1.40", + "3.80" + ], + [ + "2,000개 이하", + "0.90", + "2.70", + "6,200개 이하", + "1.50", + "4.00" + ], + [ + "2,700개 이하", + "1.00", + "3.00", + "6,900개 이하", + "1.60", + "4.20" + ], + [ + "3,400개 이하", + "1.10", + "3.20", + "7,600개 이하", + "1.70", + "4.40" + ], + [ + "4,100개 이하", + "1.20", + "3.40", + "7,600개 초과", + "1.80", + "4.60" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-01-02", + "number": "8-1-2", + "name": "약제주입병", + "level": 3, + "parent_code": "FP-08-01", + "sort_order": 57088, + "tables": [ + { + "pum_table_id": "F0202", + "section": "8-1-2. 약제주입병", + "source_line": 3964, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "300개 이하", + "900개 이하", + "1,500개 이하" + ], + "condition_note": [ + "천공수", + "소요인력", + "천공수", + "소요인력" + ], + "raw_row": [ + [ + "특별인부", + "보통인부", + "특별인부", + "보통인부", + "", + "" + ], + [ + "300개 이하", + "0.31", + "0.93", + "2,300개 이하", + "0.95", + "2.85" + ], + [ + "900개 이하", + "0.69", + "2.07", + "3,700개 이하", + "1.10", + "3.10" + ], + [ + "1,500개 이하", + "0.83", + "2.49", + "4,400개 이하", + "1.15", + "3.30" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-02", + "number": "8-2", + "name": "약제주입 기준", + "level": 2, + "parent_code": "FP-08", + "sort_order": 57344, + "tables": [] + }, + { + "work_item_code": "FP-08-02-01", + "number": "8-2-1", + "name": "소나무재선충병", + "level": 3, + "parent_code": "FP-08-02", + "sort_order": 57600, + "tables": [ + { + "pum_table_id": "F0203", + "section": "8-2-1. 소나무재선충병", + "source_line": 3982, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10~12", + "14~16", + "18~20", + "22~24", + "26~28", + "30~32", + "34~36", + "38~40", + "42~44", + "46~48", + "50~52", + "54~56" + ], + "condition_note": [ + "가슴높이 지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)", + "가슴높이 지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10~12", + "1", + "4", + "4", + "58~60", + "5", + "4", + "20" + ], + [ + "14~16", + "2", + "4", + "8", + "62~64", + "5", + "4", + "20" + ], + [ + "18~20", + "2", + "4", + "8", + "66~68", + "6", + "4", + "24" + ], + [ + "22~24", + "2", + "4", + "8", + "70~72", + "6", + "4", + "24" + ], + [ + "26~28", + "3", + "4", + "12", + "74~76", + "6", + "4", + "24" + ], + [ + "30~32", + "3", + "4", + "12", + "78~80", + "6", + "4", + "24" + ], + [ + "34~36", + "3", + "4", + "12", + "82~84", + "7", + "4", + "28" + ], + [ + "38~40", + "3", + "4", + "12", + "86~88", + "7", + "4", + "28" + ], + [ + "42~44", + "4", + "4", + "16", + "90~92", + "7", + "4", + "28" + ], + [ + "46~48", + "4", + "4", + "16", + "94~96", + "8", + "4", + "32" + ], + [ + "50~52", + "4", + "4", + "16", + "98~100", + "8", + "4", + "32" + ], + [ + "54~56", + "5", + "4", + "20", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0204", + "section": "8-2-1. 소나무재선충병", + "source_line": 3999, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10~12", + "14~16", + "18~20", + "22~24", + "26~28", + "30~32", + "34~36", + "38~40", + "42~44", + "46~48" + ], + "condition_note": [ + "가슴높이 지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)", + "가슴높이 지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10~12", + "2", + "4", + "8", + "50~52", + "6", + "5", + "30" + ], + [ + "14~16", + "2", + "5", + "10", + "54~56", + "6", + "5", + "30" + ], + [ + "18~20", + "3", + "5", + "15", + "58~60", + "7", + "5", + "35" + ], + [ + "22~24", + "3", + "5", + "15", + "62~64", + "7", + "5", + "35" + ], + [ + "26~28", + "3", + "5", + "15", + "66~68", + "7", + "5", + "35" + ], + [ + "30~32", + "4", + "5", + "20", + "70~72", + "8", + "5", + "40" + ], + [ + "34~36", + "4", + "5", + "20", + "74~76", + "8", + "5", + "40" + ], + [ + "38~40", + "5", + "5", + "25", + "78~80", + "9", + "5", + "45" + ], + [ + "42~44", + "5", + "5", + "25", + "82~84", + "9", + "5", + "45" + ], + [ + "46~48", + "6", + "5", + "30", + "86~88", + "9", + "5", + "45" + ] + ] + }, + { + "pum_table_id": "F0205", + "section": "8-2-1. 소나무재선충병", + "source_line": 4014, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "아바멕틴", + "에마멕틴벤조에이트", + "아바멕틴(1.8) 설폭사플로르(4.2)", + "아세타미프리드(10) 에마멕틴벤조에이트(6)", + "아세타미프리드(8) 에마멕틴벤조에이트(2)", + "아바멕틴(1.6) 아세타미프리드(7)" + ], + "condition_note": [ + "구 분", + "제 형" + ], + "raw_row": [ + [ + "아바멕틴", + "유제 1.8%, 분산성액제 1.8%, 미탁제 1.8%" + ], + [ + "에마멕틴벤조에이트", + "유제 2.15%, 액제 2%, 미탁제 2.15%" + ], + [ + "아바멕틴(1.8) 설폭사플로르(4.2)", + "분산성액제 6%" + ], + [ + "아세타미프리드(10) 에마멕틴벤조에이트(6)", + "액제 16%" + ], + [ + "아세타미프리드(8) 에마멕틴벤조에이트(2)", + "분산성액제 10%" + ], + [ + "아바멕틴(1.6) 아세타미프리드(7)", + "미탁제 8.6%" + ] + ] + }, + { + "pum_table_id": "F0206", + "section": "8-2-1. 소나무재선충병", + "source_line": 4023, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10~12", + "14~16", + "18~20", + "22~24", + "26~28", + "30~32", + "34~36", + "38~40", + "42~44", + "46~48", + "50~52", + "54~56" + ], + "condition_note": [ + "가슴높이 지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)", + "가슴높이 지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10~12", + "3", + "3~4", + "10", + "58~60", + "17", + "5", + "85" + ], + [ + "14~16", + "3", + "5", + "15", + "62~64", + "19", + "5", + "95" + ], + [ + "18~20", + "4", + "5", + "20", + "66~68", + "20", + "5", + "100" + ], + [ + "22~24", + "5", + "5", + "25", + "70~72", + "22", + "5", + "110" + ], + [ + "26~28", + "6", + "5", + "30", + "74~76", + "23", + "5", + "115" + ], + [ + "30~32", + "7", + "5", + "35", + "78~80", + "24", + "5", + "120" + ], + [ + "34~36", + "8", + "5", + "40", + "82~84", + "25", + "5", + "125" + ], + [ + "38~40", + "9", + "5", + "45", + "86~88", + "26", + "5", + "130" + ], + [ + "42~44", + "10", + "5", + "50", + "90~92", + "28", + "5", + "140" + ], + [ + "46~48", + "12", + "5", + "60", + "94~96", + "29", + "5", + "145" + ], + [ + "50~52", + "14", + "5", + "70", + "98~100", + "30", + "5", + "150" + ], + [ + "54~56", + "15", + "5", + "75", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0207", + "section": "8-2-1. 소나무재선충병", + "source_line": 4041, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10~16", + "18~22", + "24~26", + "28~32", + "34~36", + "38~42", + "44~46", + "48~52", + "54~56", + "58~62", + "64~66" + ], + "condition_note": [ + "가슴높이지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)", + "가슴높이지름(㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10~16", + "1", + "60", + "60", + "68~72", + "12", + "60", + "720" + ], + [ + "18~22", + "2", + "60", + "120", + "74~76", + "13", + "60", + "780" + ], + [ + "24~26", + "3", + "60", + "180", + "78~82", + "14", + "60", + "840" + ], + [ + "28~32", + "4", + "60", + "240", + "84~86", + "15", + "60", + "900" + ], + [ + "34~36", + "5", + "60", + "300", + "88~92", + "16", + "60", + "960" + ], + [ + "38~42", + "6", + "60", + "360", + "94~96", + "17", + "60", + "1,020" + ], + [ + "44~46", + "7", + "60", + "420", + "98~102", + "18", + "60", + "1,080" + ], + [ + "48~52", + "8", + "60", + "480", + "104~106", + "19", + "60", + "1,140" + ], + [ + "54~56", + "9", + "60", + "540", + "108~112", + "20", + "60", + "1,200" + ], + [ + "58~62", + "10", + "60", + "600", + "114~116", + "21", + "60", + "1,260" + ], + [ + "64~66", + "11", + "60", + "660", + "118~122", + "22", + "60", + "1,320" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-02-02", + "number": "8-2-2", + "name": "솔잎혹파리", + "level": 3, + "parent_code": "FP-08-02", + "sort_order": 57856, + "tables": [ + { + "pum_table_id": "F0208", + "section": "8-2-2. 솔잎혹파리", + "source_line": 4063, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "티아메톡삼 분산성액제 15%", + "디노테퓨란(15)․에마멕틴벤조에이트(4.5) 분산성액제 19.5%", + "이미다클로프리드 분산성액제 20%", + "아세타미프리드 액제 20%", + "디노테퓨란 액제 10%", + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%" + ], + "condition_note": [ + "선정약제", + "원액 주입량", + "비고" + ], + "raw_row": [ + [ + "티아메톡삼 분산성액제 15%", + "0.2㎖/㎝", + "" + ], + [ + "디노테퓨란(15)․에마멕틴벤조에이트(4.5) 분산성액제 19.5%", + "0.2㎖/㎝", + "" + ], + [ + "이미다클로프리드 분산성액제 20%", + "0.3㎖/㎝", + "" + ], + [ + "아세타미프리드 액제 20%", + "0.3㎖/㎝", + "" + ], + [ + "디노테퓨란 액제 10%", + "0.3㎖/㎝", + "" + ], + [ + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%", + "1㎖/㎝", + "" + ] + ] + }, + { + "pum_table_id": "F0209", + "section": "8-2-2. 솔잎혹파리", + "source_line": 4080, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "1", + "4", + "4" + ], + [ + "14∼16", + "1", + "4", + "4" + ], + [ + "18∼20", + "1", + "4", + "4" + ], + [ + "22∼24", + "2", + "4", + "8" + ], + [ + "26∼28", + "2", + "4", + "8" + ], + [ + "30∼32", + "2", + "4", + "8" + ], + [ + "34∼36", + "2", + "4", + "8" + ], + [ + "38∼40", + "2", + "4", + "8" + ], + [ + "42∼44", + "3", + "4", + "12" + ], + [ + "46∼48", + "3", + "4", + "12" + ], + [ + "50∼52", + "3", + "4", + "12" + ], + [ + "54∼56", + "3", + "4", + "12" + ], + [ + "58∼60", + "3", + "4", + "12" + ], + [ + "62∼64", + "4", + "4", + "16" + ], + [ + "66∼68", + "4", + "4", + "16" + ], + [ + "70∼72", + "4", + "4", + "16" + ], + [ + "74∼76", + "4", + "4", + "16" + ], + [ + "78∼80", + "5", + "4", + "20" + ], + [ + "82∼84", + "5", + "4", + "20" + ], + [ + "86∼88", + "5", + "4", + "20" + ], + [ + "90∼92", + "5", + "4", + "20" + ], + [ + "94∼96", + "5", + "4", + "20" + ], + [ + "98∼100", + "5", + "4", + "20" + ] + ] + }, + { + "pum_table_id": "F0210", + "section": "8-2-2. 솔잎혹파리", + "source_line": 4108, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "1", + "4", + "4" + ], + [ + "14∼16", + "2", + "4", + "8" + ], + [ + "18∼20", + "2", + "4", + "8" + ], + [ + "22∼24", + "2", + "4", + "8" + ], + [ + "26∼28", + "3", + "4", + "12" + ], + [ + "30∼32", + "3", + "4", + "12" + ], + [ + "34∼36", + "3", + "4", + "12" + ], + [ + "38∼40", + "3", + "4", + "12" + ], + [ + "42∼44", + "4", + "4", + "16" + ], + [ + "46∼48", + "4", + "4", + "16" + ], + [ + "50∼52", + "4", + "4", + "16" + ], + [ + "54∼56", + "5", + "4", + "20" + ], + [ + "58∼60", + "5", + "4", + "20" + ], + [ + "62∼64", + "5", + "4", + "20" + ], + [ + "66∼68", + "6", + "4", + "24" + ], + [ + "70∼72", + "6", + "4", + "24" + ], + [ + "74∼76", + "6", + "4", + "24" + ], + [ + "78∼80", + "6", + "4", + "24" + ], + [ + "82∼84", + "7", + "4", + "28" + ], + [ + "86∼88", + "7", + "4", + "28" + ], + [ + "90∼92", + "7", + "4", + "28" + ], + [ + "94∼96", + "8", + "4", + "32" + ], + [ + "98∼100", + "8", + "4", + "32" + ] + ] + }, + { + "pum_table_id": "F0211", + "section": "8-2-2. 솔잎혹파리", + "source_line": 4136, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "3", + "4", + "12" + ], + [ + "14∼16", + "4", + "4", + "16" + ], + [ + "18∼20", + "5", + "4", + "20" + ], + [ + "22∼24", + "6", + "4", + "24" + ], + [ + "26∼28", + "7", + "4", + "28" + ], + [ + "30∼32", + "8", + "4", + "32" + ], + [ + "34∼36", + "9", + "4", + "36" + ], + [ + "38∼40", + "10", + "4", + "40" + ], + [ + "42∼44", + "11", + "4", + "44" + ], + [ + "46∼48", + "12", + "4", + "48" + ], + [ + "50∼52", + "13", + "4", + "52" + ], + [ + "54∼56", + "14", + "4", + "56" + ], + [ + "58∼60", + "15", + "4", + "60" + ], + [ + "62∼64", + "16", + "4", + "64" + ], + [ + "66∼68", + "18", + "4", + "72" + ], + [ + "70∼72", + "18", + "4", + "72" + ], + [ + "74∼76", + "19", + "4", + "76" + ], + [ + "78∼80", + "20", + "4", + "80" + ], + [ + "82∼84", + "21", + "4", + "84" + ], + [ + "86∼88", + "22", + "4", + "88" + ], + [ + "90∼92", + "23", + "4", + "92" + ], + [ + "94∼96", + "24", + "4", + "96" + ], + [ + "98∼100", + "25", + "4", + "100" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-02-03", + "number": "8-2-3", + "name": "솔껍질깍지벌레", + "level": 3, + "parent_code": "FP-08-02", + "sort_order": 58112, + "tables": [ + { + "pum_table_id": "F0212", + "section": "8-2-3. 솔껍질깍지벌레", + "source_line": 4166, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "디노테퓨란(15)․에마멕틴벤조에이트(4.5) 분산성액제 19.5%", + "이미다클로프리드 분산성액제 20%", + "티아메톡삼 분산성액제 15%", + "에마멕틴벤조에이트 유제 2.15%", + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%" + ], + "condition_note": [ + "선정약제", + "원액 주입량", + "비고" + ], + "raw_row": [ + [ + "디노테퓨란(15)․에마멕틴벤조에이트(4.5) 분산성액제 19.5%", + "0.5㎖/㎝", + "" + ], + [ + "이미다클로프리드 분산성액제 20%", + "0.6㎖/㎝", + "" + ], + [ + "티아메톡삼 분산성액제 15%", + "0.6㎖/㎝", + "" + ], + [ + "에마멕틴벤조에이트 유제 2.15%", + "1㎖/㎝", + "" + ], + [ + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%", + "1㎖/㎝", + "" + ] + ] + }, + { + "pum_table_id": "F0213", + "section": "8-2-3. 솔껍질깍지벌레", + "source_line": 4182, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "2", + "4", + "8" + ], + [ + "14∼16", + "2", + "4", + "8" + ], + [ + "18∼20", + "3", + "4", + "12" + ], + [ + "22∼24", + "3", + "4", + "12" + ], + [ + "26∼28", + "4", + "4", + "16" + ], + [ + "30∼32", + "4", + "4", + "16" + ], + [ + "34∼36", + "5", + "4", + "20" + ], + [ + "38∼40", + "5", + "4", + "20" + ], + [ + "42∼44", + "6", + "4", + "24" + ], + [ + "46∼48", + "6", + "4", + "24" + ], + [ + "50∼52", + "7", + "4", + "28" + ], + [ + "54∼56", + "7", + "4", + "28" + ], + [ + "58∼60", + "8", + "4", + "32" + ], + [ + "62∼64", + "8", + "4", + "32" + ], + [ + "66∼68", + "9", + "4", + "36" + ], + [ + "70∼72", + "9", + "4", + "36" + ], + [ + "74∼76", + "10", + "4", + "40" + ], + [ + "78∼80", + "10", + "4", + "40" + ], + [ + "82∼84", + "11", + "4", + "44" + ], + [ + "86∼88", + "11", + "4", + "44" + ], + [ + "90∼92", + "12", + "4", + "48" + ], + [ + "94∼96", + "12", + "4", + "48" + ], + [ + "98∼100", + "13", + "4", + "52" + ] + ] + }, + { + "pum_table_id": "F0214", + "section": "8-2-3. 솔껍질깍지벌레", + "source_line": 4210, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "2", + "4", + "8" + ], + [ + "14∼16", + "3", + "4", + "12" + ], + [ + "18∼20", + "3", + "4", + "12" + ], + [ + "22∼24", + "4", + "4", + "16" + ], + [ + "26∼28", + "5", + "4", + "20" + ], + [ + "30∼32", + "5", + "4", + "20" + ], + [ + "34∼36", + "6", + "4", + "24" + ], + [ + "38∼40", + "6", + "4", + "24" + ], + [ + "42∼44", + "7", + "4", + "28" + ], + [ + "46∼48", + "8", + "4", + "32" + ], + [ + "50∼52", + "8", + "4", + "32" + ], + [ + "54∼56", + "9", + "4", + "36" + ], + [ + "58∼60", + "9", + "4", + "36" + ], + [ + "62∼64", + "10", + "4", + "40" + ], + [ + "66∼68", + "11", + "4", + "44" + ], + [ + "70∼72", + "11", + "4", + "44" + ], + [ + "74∼76", + "12", + "4", + "48" + ], + [ + "78∼80", + "12", + "4", + "48" + ], + [ + "82∼84", + "13", + "4", + "52" + ], + [ + "86∼88", + "14", + "4", + "56" + ], + [ + "90∼92", + "14", + "4", + "56" + ], + [ + "94∼96", + "15", + "4", + "60" + ], + [ + "98∼100", + "15", + "4", + "60" + ] + ] + }, + { + "pum_table_id": "F0215", + "section": "8-2-3. 솔껍질깍지벌레", + "source_line": 4238, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "3", + "4", + "12" + ], + [ + "14∼16", + "4", + "4", + "16" + ], + [ + "18∼20", + "5", + "4", + "20" + ], + [ + "22∼24", + "6", + "4", + "24" + ], + [ + "26∼28", + "7", + "4", + "28" + ], + [ + "30∼32", + "8", + "4", + "32" + ], + [ + "34∼36", + "9", + "4", + "36" + ], + [ + "38∼40", + "10", + "4", + "40" + ], + [ + "42∼44", + "11", + "4", + "44" + ], + [ + "46∼48", + "12", + "4", + "48" + ], + [ + "50∼52", + "13", + "4", + "52" + ], + [ + "54∼56", + "14", + "4", + "56" + ], + [ + "58∼60", + "15", + "4", + "60" + ], + [ + "62∼64", + "16", + "4", + "64" + ], + [ + "66∼68", + "18", + "4", + "72" + ], + [ + "70∼72", + "18", + "4", + "72" + ], + [ + "74∼76", + "19", + "4", + "76" + ], + [ + "78∼80", + "20", + "4", + "80" + ], + [ + "82∼84", + "21", + "4", + "84" + ], + [ + "86∼88", + "22", + "4", + "88" + ], + [ + "90∼92", + "23", + "4", + "92" + ], + [ + "94∼96", + "24", + "4", + "96" + ], + [ + "98∼100", + "25", + "4", + "100" + ] + ] + }, + { + "pum_table_id": "F0452", + "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", + "source_line": 7823, + "pum_form": "reference", + "form_basis": "헤더 '할인'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "사업 구분", + "1-4-1", + "6-4-4 뿌리제거", + "1-4-2", + "1-4-3", + "6-2-2 풀베기(줄베기)", + "6-4-1 덩굴걷기", + "1-4-4", + "1-4-5", + "5-5천연하종갱신", + "6-1 비료주기", + "숲가꾸기", + "4-2-1 단목베기", + "6-2-3 풀베기(모두베기)", + "6-4-2 덩굴약제 살포처리", + "6-5 어린나무가꾸기", + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-9-2 부탁형 타워 (K-301, HAM300)", + "국유임산물 (수확)", + "7-6 스윙야더 집재", + "7-9-1 부탁형 타워 (K-301, HAM300)", + "산림병해충방제", + "7-1-2 인력집재" + ], + "condition_note": [ + "번호", + "할인․할증 요소", + "할인․할증 반영이 필요한 공종" + ], + "raw_row": [ + [ + "사업 구분", + "세 부 공 종", + "", + "", + "", + "" + ], + [ + "1-4-1", + "작업시기", + "숲가꾸기", + "6-4-1 덩굴걷기", + "6-4-2 덩굴약제 살포처리", + "6-4-3 소금처리" + ], + [ + "6-4-4 뿌리제거", + "6-5 어린나무가꾸기", + "", + "", + "", + "" + ], + [ + "1-4-2", + "조림 후 경과연수", + "풀베기", + "6-2-2 풀베기(줄베기)", + "", + "" + ], + [ + "1-4-3", + "집단화정도", + "숲가꾸기", + "3-1 경계표시", + "4-2-1 단목베기", + "6-2-1 풀베기(둘레베기)" + ], + [ + "6-2-2 풀베기(줄베기)", + "6-2-3 풀베기(모두베기)", + "6-3 맹아제거", + "", + "", + "" + ], + [ + "6-4-1 덩굴걷기", + "6-4-2 덩굴약제 살포처리", + "6-4-3 소금처리", + "", + "", + "" + ], + [ + "6-4-4 뿌리제거", + "6-5 어린나무가꾸기", + "6-6 가지치기 및 수형교정", + "", + "", + "" + ], + [ + "1-4-4", + "작업구역", + "병해충방제", + "8-6-2 드론방제", + "", + "" + ], + [ + "1-4-5", + "경사도 (산지경사)", + "조림", + "3-5 예정지정리작업", + "5-3-1 나무식재", + "5-4 파종조림" + ], + [ + "5-5천연하종갱신", + "5-6 움싹갱신", + "5-7 생태보완조림", + "", + "", + "" + ], + [ + "6-1 비료주기", + "", + "", + "", + "", + "" + ], + [ + "숲가꾸기", + "3-1 경계표시", + "3-3 작업로 설치", + "3-6 산물 임내정리", + "", + "" + ], + [ + "4-2-1 단목베기", + "6-2-1 풀베기(둘레베기)", + "6-2-2 풀베기(줄베기)", + "", + "", + "" + ], + [ + "6-2-3 풀베기(모두베기)", + "6-3 (맹아제거)", + "6-4-1 덩굴걷기", + "", + "", + "" + ], + [ + "6-4-2 덩굴약제 살포처리", + "6-4-3 소금처리", + "6-4-4 뿌리제거", + "", + "", + "" + ], + [ + "6-5 어린나무가꾸기", + "6-6 가지치기 및 수형교정", + "7-1 인력집재", + "", + "", + "" + ], + [ + "7-2 수라집재", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "", + "" + ], + [ + "7-5-2 트랙터부착형집재(지면끌기)", + "7-6 스윙야더 집재", + "7-7-2 HAM200 집재(소형가선)", + "", + "", + "" + ], + [ + "7-9-2 부탁형 타워 (K-301, HAM300)", + "7-15-2 소형 포워더 운재", + "", + "", + "", + "" + ], + [ + "국유임산물 (수확)", + "4-1-1 수확베기 (임업용 동력기계톱)", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-5-1 트랙터부착형집재기", + "", + "" + ], + [ + "7-6 스윙야더 집재", + "7-7-1 HAM200 (춘천․스마트)", + "7-8-3 타워야더 (RME-300T)", + "", + "", + "" + ], + [ + "7-9-1 부탁형 타워 (K-301, HAM300)", + "7-11 동력상하차기 (우드그래플)집재", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "3-3 작업로 설치", + "4-2-1 단목베기", + "3-6 산물 임내정리", + "", + "" + ], + [ + "7-1-2 인력집재", + "3-7 재해산물수집", + "7-2 수라집재", + "", + "", + "" + ], + [ + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "7-5-2 트랙터부착형집재(지면끌기)", + "", + "", + "" + ], + [ + "7-7-2 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "7-10 굴착기 우드그랩 산지집재", + "", + "", + "" + ], + [ + "8-1-1 나무주사", + "8-4 끈끈이 롤 트랩", + "", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-3 작업로 설치", + "4-2-1 단목베기", + "7-3 아키야윈치 (임업용 윈치)", + "", + "" + ], + [ + "7-4-2 소형가선집재 (2드럼윈치)", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-7-2 HAM200 집재(소형가선)", + "", + "", + "" + ], + [ + "7-9-2 부착형 타워 (K-301, HAM300)", + "8-1-1 나무주사", + "8-6-2 드론방제", + "", + "", + "" + ], + [ + "1-4-6", + "작업장까지의 이동거리", + "조림", + "3-5 예정지정리작업", + "5-3-1 나무식재", + "5-4 파종조림" + ], + [ + "5-5 천연하종갱신", + "5-6 움싹갱신", + "5-7 생태보완조림", + "", + "", + "" + ], + [ + "6-1 비료주기", + "", + "", + "", + "", + "" + ], + [ + "1-4-7", + "하층식생", + "숲가꾸기", + "6-4-3 소금처리", + "6-4-4 뿌리제거", + "" + ], + [ + "1-4-8", + "장애물의 정도", + "숲가꾸기", + "3-2 작업로 선정", + "4-2-1 단목베기", + "6-6 가지치기 및 수형교정" + ], + [ + "7-1-2 인력집재", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "", + "" + ], + [ + "국유임산물 (수확)", + "4-1-1 수확베기 (임업용 동력기계톱)", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "4-2-1 단목베기", + "3-7 재해산물수집", + "7-1-2 인력집재", + "", + "" + ], + [ + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "8-1-1 나무주사", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-7 재해산물수집", + "4-2-1 단목베기", + "7-1-2 인력집재", + "", + "" + ], + [ + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "8-3 소각, 매몰, 훈증, 박피", + "", + "", + "" + ], + [ + "1-4-9", + "제거대상 식생", + "숲가꾸기", + "3-3 작업로 설치", + "", + "" + ], + [ + "산림병해충방제", + "3-3 작업로 설치", + "", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-3 작업로 설치", + "", + "", + "", + "" + ], + [ + "1-4-10", + "토양상태 (토양조건)", + "조림", + "5-3-1 나무식재", + "5-4 파종조림", + "5-5 천연하종갱신" + ], + [ + "5-7 생태보완조림", + "6-1 비료주기", + "", + "", + "", + "" + ], + [ + "숲가꾸기", + "6-4-4 뿌리제거", + "", + "", + "", + "" + ], + [ + "1-4-11", + "덩굴피복도", + "숲가꾸기", + "6-4-1 덩굴걷기", + "", + "" + ], + [ + "1-4-12", + "제거대상 피복도", + "어린나무가꾸기", + "6-5 어린나무가꾸기", + "", + "" + ], + [ + "1-4-13", + "주행 장애물 상태", + "숲가꾸기", + "7-15-2 초소형포워더운재", + "", + "" + ], + [ + "국유임산물 (수확)", + "7-15-1 초소형포워더운재", + "7-16 소형포워더 운재", + "7-17 소형트럭 운재", + "", + "" + ], + [ + "7-18 지조운반 (포워더 활용)", + "", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "7-10 굴착기 우드그랩 산지집재", + "7-15-2 초소형포워더운재", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "7-15-2 초소형포워더운재", + "", + "", + "", + "" + ], + [ + "1-4-14", + "집재방향", + "숲가꾸기", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-7-2 HAM200 집재(소형가선)", + "" + ], + [ + "국유임산물 (수확)", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-5-1 트랙터부착형집재(지면끌기)", + "7-6 스윙야더 집재", + "", + "" + ], + [ + "7-7-1 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "7-9-1 부착형 타워 (K-301, HAM300)", + "", + "", + "" + ], + [ + "산림병해충방제", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-7-2 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "", + "" + ], + [ + "소나무재선충병 방제", + "3-7 재해산물수집", + "7-1-2 인력집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "", + "" + ], + [ + "7-7-2 HAM200 집재(소형가선)", + "7-9-2 부착형 타워 (K-301, HAM300)", + "8-8-1 훈증더미제거 (인력)", + "", + "", + "" + ], + [ + "1-4-15", + "횡단운반거리 (측방집재거리)", + "숲가꾸기", + "7-2 수라집재", + "7-7-2 HAM200 집재(소형가선)", + "7-9-2 부착형 타워 (K-301, HAM300)" + ], + [ + "국유임산물 (수확)", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-6 스윙야더 집재", + "7-7-1 HAM200 집재(소형가선)", + "", + "" + ], + [ + "7-8-3 타워야더 (RME-300T)", + "7-9-1 부착형 타워 (K-301, HAM300)", + "", + "", + "", + "" + ], + [ + "산림병해충방제", + "7-7-2 HAM200 집재(소형가선)", + "7-8-3 타워야더 (RME-300T)", + "", + "", + "" + ], + [ + "소나무재선충병 방제", + "7-2 수라집재", + "7-7-2 HAM200 집재(소형가선)", + "7-9-2 부착형 타워 (K-301, HAM300)", + "", + "" + ], + [ + "1-4-16", + "규격재 생산", + "숲가꾸기", + "4-2-1 단목베기", + "", + "" + ], + [ + "1-4-17", + "방제대상목 분포", + "산림병해충방제", + "4-2-1 단목베기", + "8-1-1 나무주사", + "8-4 끈끈이 롤 트랩" + ], + [ + "소나무재선충병 방제", + "8-3 소각, 매몰, 훈증, 박피", + "", + "", + "", + "" + ], + [ + "1-4-18", + "방제대상목 평균 경급", + "산림병해충방제", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "" + ], + [ + "소나무재선충병 방제", + "7-3 아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "8-10 그물망 피복", + "", + "" + ], + [ + "1-4-19", + "매개충 나무주사", + "소나무재선충병 방제", + "8-1-1 나무주사", + "", + "" + ], + [ + "1-4-20", + "방제지 사면", + "소나무재선충병 방제", + "8-6-2 드론방제", + "", + "" + ], + [ + "1-4-21", + "방제지 접근성", + "소나무재선충병 방제", + "8-6-2 드론방제", + "", + "" + ], + [ + "1-4-22", + "방제 수종", + "소나무재선충병 방제", + "8-3 소각, 매몰, 훈증, 박피", + "", + "" + ], + [ + "1-4-23", + "방제목 운반거리", + "소나무재선충병 방제", + "8-10 그물망 피복", + "", + "" + ], + [ + "1-4-24", + "방제장비 규격", + "소나무재선충병 방제", + "8-8-2 훈증더미제거 (기계)", + "", + "" + ], + [ + "1-4-25", + "작업시간 제한", + "모든 사업", + "모든 공종", + "", + "" + ], + [ + "1-4-26", + "소규모 작업물량 제한", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0453", + "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", + "source_line": 7907, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "공종", + "조림", + "5-32 대절작업", + "5-5 천연하종갱신", + "6-1 비료주기", + "지침 외", + "5-3-2 관목식재(단식)", + "10-13 드론운반공", + "숲가 꾸기", + "6-2-2 줄베기 (풀베기)", + "6-4-2 덩굴 약제 살포처리", + "4-2-1 단목베기", + "7-3아키야윈치 (임업용 윈치)", + "7-15-2 초소형포워더 운재", + "7-9-2 부착형타워 K-301, HAM300 (중형가선)", + "국유 임산물 수확", + "7-4-1 소형가선집재 (2드럼윈치)", + "7-8-3 타워야더 RME-300T", + "7-1-1 인력집재 (수확)", + "7-18 지조운반 (포워더 활용)", + "3-4-2 임산물 운반로 및 작업로 신설", + "산림 병해충 방제", + "8-1-2 약제주입병", + "8-2-5 약제주입기준 (푸사리움가지마름병)" + ], + "condition_note": [ + "사업종", + "구분", + "적용 가능 공종" + ], + "raw_row": [ + [ + "공종", + "", + "", + "", + "", + "" + ], + [ + "조림", + "시행 지침", + "3-5 예정지 정리작업", + "5-3-1 나무식재", + "5-30 표시봉설치", + "5-31 지주목설치" + ], + [ + "5-32 대절작업", + "5-33 묘목 가식작업", + "5-34 묘목 소운반", + "5-4 파종조림", + "", + "" + ], + [ + "5-5 천연하종갱신", + "5-6 움싹갱신", + "5-7 생태보완조림", + "5-8 큰나무 공익조림", + "", + "" + ], + [ + "6-1 비료주기", + "", + "", + "", + "", + "" + ], + [ + "지침 외", + "5-1-1 관목굴취", + "5-1-2 교목굴취(나무높이)", + "5-1-3 교목굴취(근원직경)", + "5-2 뿌리돌림", + "" + ], + [ + "5-3-2 관목식재(단식)", + "5-3-3 관목식재(군식)", + "5-3-4 교목식재 (나무높이)", + "5-3-5 교목식재 (흉고직경)", + "", + "" + ], + [ + "10-13 드론운반공", + "", + "", + "", + "", + "" + ], + [ + "숲가 꾸기", + "시행 지침", + "3-1 경계표시", + "3-2 작업로 선정", + "3-3 작업로 설치", + "6-2-1 둘러베기 (풀베기)" + ], + [ + "6-2-2 줄베기 (풀베기)", + "6-2-3 모두베기 (풀베기)", + "6-3 맹아제거", + "6-4-1 덩굴걷기", + "", + "" + ], + [ + "6-4-2 덩굴 약제 살포처리", + "6-4-3 소금처리", + "6-4-4 뿌리제거", + "6-5어린나무 가꾸기", + "", + "" + ], + [ + "4-2-1 단목베기", + "4-3 위험목 베기", + "6-6 가지치기 및 수형교정", + "7-1-2 인력집재", + "", + "" + ], + [ + "7-3아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "", + "" + ], + [ + "7-15-2 초소형포워더 운재", + "7-7-2 HAM200 집재(소형가선)", + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "", + "" + ], + [ + "7-9-2 부착형타워 K-301, HAM300 (중형가선)", + "7-12 동력상하차기 (우드그래플) 집적", + "3-6 산물 임내정리", + "", + "", + "" + ], + [ + "지침 외", + "3-5 드론 영상 촬영", + "4-5 벌도 위험목 점검", + "4-6 벌목부 작업안전 보조", + "7-13 검척", + "" + ], + [ + "국유 임산물 수확", + "시행 지침", + "4-1-1 수확베기", + "4-1-2 하베스터 베기", + "7-3아키야윈치 (임업용 윈치)", + "7-5-1 트랙터부착형집재(지면끌기)" + ], + [ + "7-4-1 소형가선집재 (2드럼윈치)", + "7-6 스윙야더 집재", + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "", + "" + ], + [ + "7-8-3 타워야더 RME-300T", + "7-7-1 HAM200 집재(소형가선)", + "7-9-1 부착형 타워 (K-301, HAM300)", + "7-11 동력상하차기 (우드그래플) 집재", + "", + "" + ], + [ + "7-1-1 인력집재 (수확)", + "7-15-1 초소형포워더 운재", + "7-16 소형포워더 운재", + "7-17 소형트럭 운재", + "", + "" + ], + [ + "7-18 지조운반 (포워더 활용)", + "7-12 동력상하차기 (우드그래플) 집적", + "7-13 검척", + "7-14 원목 운반 (수확)", + "", + "" + ], + [ + "3-4-2 임산물 운반로 및 작업로 신설", + "3-4-3 임산물 운반로 및 작업로 보수", + "", + "", + "", + "" + ], + [ + "지침 외", + "4-5 벌도 위험목 점검", + "4-6 벌목부 작업안전 보조", + "", + "", + "" + ], + [ + "산림 병해충 방제", + "시행 지침", + "3-3 작업로 설치", + "4-2-1 단목베기", + "3-6 산물 임내정리", + "8-1-1 나무주사" + ], + [ + "8-1-2 약제주입병", + "8-2-2 약제주입기준 (솔잎혹파리)", + "8-2-3 약제주입기준 (솔껍질깍지벌레)", + "8-2-4 약제주입기준 (솔나방)", + "", + "" + ], + [ + "8-2-5 약제주입기준 (푸사리움가지마름병)", + "8-3 소각, 매몰, 훈증, 박피", + "8-11 이동식 임목파쇄", + "8-4 끈끈이 롤 트랩", + "", + "" + ], + [ + "7-1-2 인력집재", + "3-4 재해산물 수집", + "7-3아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "" + ], + [ + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-16 소형포워더 운재", + "7-7-2 HAM200 집재(소형가선)", + "", + "" + ], + [ + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "7-8-3 타워야더 RME-300T", + "7-12 동력상하차기 (우드그래플)집적", + "", + "" + ], + [ + "7-10 굴착기 우드그랩 산지집재", + "4-3 위험목 베기", + "8-6-1 유인헬기방제", + "8-6-3 지상방제", + "", + "" + ], + [ + "지침 외", + "3-5 드론 영상 촬영", + "4-5 벌도 위험목점검", + "4-6 벌목부 작업안전 보조", + "7-13 검척", + "" + ], + [ + "소나무 재선충병 방제", + "시행 지침", + "3-3 작업로 설치", + "8-1-1 나무주사", + "8-1-2 약제주입병", + "8-2-1 약제주입기준 (소나무재선충병)" + ], + [ + "8-6-2 드론방제", + "8-6-3 지상방제", + "8-5 페르몬 유인트랩 (매개충 유인트랩)", + "4-1 수확베기 (모두베기)", + "", + "" + ], + [ + "8-3 소각, 매몰, 훈증, 박피", + "4-2-1 단목베기 (강도간벌)", + "8-9 잔가지 줍기", + "8-11 이동식 임목 파쇄", + "", + "" + ], + [ + "8-10 그물망 피복", + "8-8-1 훈증더미 제거 (인력)", + "8-8-2 훈증더미 제거 (기계)", + "4-3 위험목 베기", + "", + "" + ], + [ + "8-7 방제 실행등록", + "7-1-2 인력집재", + "7-3아키야윈치 (임업용 윈치)", + "7-4-2 소형가선집재 (2드럼윈치)", + "", + "" + ], + [ + "7-2 수라집재", + "7-5-2 트랙터부착형집재(지면끌기)", + "7-16 소형포워더 운재", + "7-7-2 HAM200 집재(소형가선)", + "", + "" + ], + [ + "7-8-1 가선설치 (중형가선)", + "7-8-2 가선해체 (중형가선)", + "7-8-3 타워야더 RME-300T", + "7-9-2 부착형 타워 (K-301, HAM300)", + "", + "" + ], + [ + "7-12 동력상하차기 (우드그래플)집적", + "7-10 굴착기 우드그랩 산지집재", + "", + "", + "", + "" + ], + [ + "지침 외", + "3-5 드론 영상 촬영", + "4-4 가지정리", + "4-5 벌도 위험목점검", + "4-6 벌목부 작업안전 보조", + "" + ], + [ + "임도, 사방,복원", + "품셈", + "4-2-2 단목베기 (1,000㎡)", + "5-1-4 떼채취", + "5-9 해안조림", + "5-10 사방조림" + ], + [ + "5-11 사초심기", + "5-12 떼붙임(재배잔디)", + "5-13 떼심기", + "5-14 새심기", + "", + "" + ], + [ + "5-15 바자얽기", + "5-16 선떼붙이기공", + "5-17 조공", + "5-18 씨뿌리기(줄)", + "", + "" + ], + [ + "5-19 표토 절취 및 정지", + "5-20 표토관리", + "5-21 표토이식", + "5-22 평떼", + "", + "" + ], + [ + "5-23 줄떼", + "5-24 씨앗뿜어붙이기", + "5-25 거적덮기", + "5-26 흙갈이", + "", + "" + ], + [ + "5-27 식재면 관리", + "5-28 비탈덮기", + "5-29 복사이식", + "9-1 굴착", + "", + "" + ], + [ + "9-2 노선 굴진보조원", + "9-3 토사깍기", + "9-4 암절취", + "9-5 발파암", + "", + "" + ], + [ + "9-6 발파암 소할", + "9-7 무근콘크리트 깨기", + "9-8 철근콘크리트깨기", + "9-9 석축헐기", + "", + "" + ], + [ + "9-10 기존포장 깨기", + "9-11 포장절단", + "9-12 측구터파기", + "9-13 구조물터파기", + "", + "" + ], + [ + "9-14 되메우기 및 다짐", + "9-15 표토제거", + "9-16 노체", + "9-17 다짐", + "", + "" + ], + [ + "9-18 층따기", + "9-19 면고르기", + "9-20 뿌리다듬기 및 적재", + "9-21 제근", + "", + "" + ], + [ + "9-22 섞기", + "10-1 시멘트운반", + "10-2 철근운반", + "10-3 골재운반", + "", + "" + ], + [ + "10-4 중기운반", + "10-5 레미콘 운반비 산정", + "10-6 인력운반", + "10-7 모노레일 운반", + "", + "" + ], + [ + "10-8 케이블 크레인 운반", + "10-9 덤프트럭 운반", + "10-10 헬리콥터 자재 운반", + "10-11 불도저 운반", + "", + "" + ], + [ + "10-12 덤프 운반", + "11-1 콘테이너형 가설건축물", + "11-2 토공의 비탈 규준틀", + "11-3 수평규준틀", + "", + "" + ], + [ + "11-4 쇄석․혼합석 부설", + "12-1 콘크리트타설", + "12-2 표면 마무리", + "12-3 철근 현장가공 및 조림", + "", + "" + ], + [ + "12-4 합판거푸집", + "12-5 문양거푸집", + "12-6 콘크리트 포장 (인력시공)", + "12-7 포장절단 및 줄눈설치", + "", + "" + ], + [ + "12-8 콘크리트 포장 거푸집", + "12-9 측구", + "12-10 맹암거", + "12-11 관부설", + "", + "" + ], + [ + "12-12 날개벽", + "12-13 면벽", + "12-14 가배수관", + "12-15 집수정", + "", + "" + ], + [ + "12-16 맨홀", + "12-17 펌프카", + "12-18 철근가공조립 (복잡)", + "12-19 강관비계", + "", + "" + ], + [ + "12-20 강관동바리", + "12-21 아스팔트코팅(2회)", + "12-22 P.V.C 파이프 설치(50㎜)", + "12-23 부직포설치", + "", + "" + ], + [ + "12-24 뒷채움 및 되메우기", + "12-25 기초잡석", + "12-26 물푸기", + "12-27 지수판", + "", + "" + ], + [ + "12-28 신구 BOX접합", + "12-29 스페이셔 설치 (몰탈 블룩)", + "12-30 다웰바 설치", + "12-31 물끊기 홈 (NOTCH) 설치", + "", + "" + ], + [ + "12-32 전선관 설치 (P.V.C ∅54m/m)", + "12-33 비닐깔기", + "12-34 암거이음받침", + "12-35 콘크리트 표면 강화재(하드너)", + "", + "" + ], + [ + "12-36 P.C BOX 설치", + "12-37 콘크리트 타설 (무근진동기 제외)", + "12-38 유로폼", + "13-1 석재 및 골재", + "", + "" + ], + [ + "13-2 채집 및 세척", + "13-3 기초다짐 및 뒷채움", + "13-4 돌쌓기", + "13-5 돌붙임", + "", + "" + ], + [ + "13-6 큰돌쌓기", + "13-7 큰돌붙이기", + "13-8 막돌쌓기", + "13-9 서식지 조성 돌쌓기 및 놓기", + "", + "" + ], + [ + "13-10 말뚝박기공", + "13-11 돌망태", + "13-12 비탈다듬기", + "13-13 흙막이", + "", + "" + ], + [ + "13-14 식생토낭 및 포트", + "13-15 목재공", + "13-16 매트부설", + "14-1 입목뿌리 밑막이", + "", + "" + ], + [ + "14-2 근주이식", + "", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0454", + "section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.", + "source_line": 7983, + "pum_form": "reference", + "form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "위치 및 면적", + "구분", + "단 위 작 업 별", + "- 직접노무비", + "ㆍ벌채부산물 임내정리 (벌채와 동시 정리지역)", + "- 재료비(체인톱)", + "ㆍ보통휘발유(주연료)", + "ㆍ보통휘발유(잡품)", + "ㆍ체인오일(친환경)", + "- 기계경비(체인톱)", + "2. 식 재", + "ㆍ식재작업", + "ㆍ표시봉설치", + "ㆍ소운반", + "- 재료비", + "ㆍ묘목", + "ㆍ표시봉", + "- 경 비", + "ㆍ대운반", + "합 계", + "직접노무비", + "재 료 비", + "경비(기계경비)", + "<할인․할증률 적용>" + ], + "condition_note": [ + "ha당 조림 단가산출서(예시)" + ], + "raw_row": [ + [ + "위치 및 면적", + "1-0-1-0 또는 00임반 00소반", + "비 고", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구분", + "작업량", + "단위품 (인원,수량,요율)", + "소요품", + "단가(원)", + "할인 ㆍ 증률", + "계(원)", + "", + "", + "", + "", + "" + ], + [ + "", + "단위작업", + "", + "단위", + "", + "단위", + "", + "종류", + "", + "", + "", + "" + ], + [ + "단 위 작 업 별", + "1. 예정지정리", + "", + "", + "", + "", + "", + "", + "", + "", + "1,619,278", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "1,506,543", + "", + "" + ], + [ + "ㆍ벌채부산물 임내정리 (벌채와 동시 정리지역)", + "1.0", + "ha", + "7.00", + "인", + "7.00", + "195,655", + "특별50% 보통50%", + "10%", + "1,506,543", + "2인 1조", + "" + ], + [ + "", + "", + "", + "", + "", + "(221,506)", + "(특별인부)", + "", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "(169,804)", + "(보통인부)", + "", + "", + "", + "", + "" + ], + [ + "- 재료비(체인톱)", + "", + "", + "", + "", + "", + "", + "", + "", + "86,275", + "", + "" + ], + [ + "ㆍ보통휘발유(주연료)", + "3.5", + "대/ha", + "5.60", + "ℓ/대", + "19.60", + "1,537", + "무연(ℓ)", + "", + "30,125", + "", + "" + ], + [ + "ㆍ보통휘발유(잡품)", + "30,125", + "원", + "40", + "%", + "", + "", + "", + "", + "12,050", + "", + "" + ], + [ + "ㆍ체인오일(친환경)", + "3.5", + "대/ha", + "2.10", + "ℓ/대", + "7.35", + "6,000", + "국내산(ℓ)", + "", + "44,100", + "", + "" + ], + [ + "- 기계경비(체인톱)", + "3.5", + "대", + "0.0084", + "", + "", + "900,000", + "체인톱(대)", + "", + "26,460", + "", + "" + ], + [ + "2. 식 재", + "", + "", + "", + "", + "", + "", + "", + "", + "3,379,002", + "", + "" + ], + [ + "- 직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "3,006,510", + "", + "" + ], + [ + "ㆍ식재작업", + "3,000", + "본/ha", + "4.00", + "인/천본", + "12.00", + "185,315", + "특별30% 보통70%", + "10%", + "2,446,158", + "소나무 2-0용(중묘)", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "(221,506)", + "(특별인부)", + "", + "", + "", + "" + ], + [ + "", + "", + "", + "", + "", + "", + "(169,804)", + "(보통인부)", + "", + "", + "", + "" + ], + [ + "ㆍ표시봉설치", + "3,000", + "본/ha", + "0.70", + "인/천본", + "2.10", + "169,804", + "보통인부", + "", + "356,588", + "", + "" + ], + [ + "ㆍ소운반", + "3,000", + "본/ha", + "0.40", + "인/천본", + "1.20", + "169,804", + "보통인부", + "", + "203,764", + "", + "" + ], + [ + "- 재료비", + "", + "", + "", + "", + "", + "", + "", + "", + "360,000", + "", + "" + ], + [ + "ㆍ묘목", + "3,000", + "본/ha", + "", + "", + "", + "0원", + "원/본", + "", + "0", + "관급자재", + "" + ], + [ + "ㆍ표시봉", + "3,000", + "개/ha", + "", + "", + "", + "120", + "원/개", + "", + "360,000", + "대나무", + "" + ], + [ + "- 경 비", + "", + "", + "", + "", + "", + "", + "", + "", + "12,492", + "", + "" + ], + [ + "ㆍ대운반", + "3,000", + "본/ha", + "", + "", + "", + "4,164", + "원/1,000본", + "", + "12,492", + "운반 50km", + "" + ], + [ + "합 계", + "순 원 가", + "", + "", + "", + "", + "", + "", + "", + "", + "4,998,280", + "" + ], + [ + "직접노무비", + "", + "", + "", + "", + "", + "", + "", + "", + "4,513,053", + "", + "" + ], + [ + "재 료 비", + "", + "", + "", + "", + "", + "", + "", + "", + "446,275", + "", + "" + ], + [ + "경비(기계경비)", + "", + "", + "", + "", + "", + "", + "", + "", + "38,952", + "", + "" + ], + [ + "<할인․할증률 적용>", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "구 분", + "할인․할증요소", + "단위작업(%)", + "비고", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "예정지정리", + "식재", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 벌채구역 크기(1-4-4)", + "개벌지 크기가 3,000㎡ 이상", + "0%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 경사도(1-4-5)", + "중경사(15∼30°)", + "5%", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 작업장까지 이동거리(1-4-6)", + "1.3㎞ 미만", + "0%", + "0%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 토양상태(1-4-10)", + "돌함량 10∼30%", + "", + "5%", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 미이용산림 바이오 메스 정리량", + "50㎥∼100㎥/ha미만", + "5%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "o 산불피해지", + "", + "0%", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "합 계", + "", + "10%", + "10%", + "", + "", + "", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-02-04", + "number": "8-2-4", + "name": "솔나방", + "level": 3, + "parent_code": "FP-08-02", + "sort_order": 58368, + "tables": [ + { + "pum_table_id": "F0216", + "section": "8-2-4. 솔나방", + "source_line": 4268, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%" + ], + "condition_note": [ + "선정약제", + "원액 주입량", + "비고" + ], + "raw_row": [ + [ + "아바멕틴(1.8)·설폭사플로르(4.2) 분산성액제 6%", + "0.4㎖/㎝", + "" + ] + ] + }, + { + "pum_table_id": "F0217", + "section": "8-2-4. 솔나방", + "source_line": 4279, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "2", + "4", + "8" + ], + [ + "14∼16", + "2", + "4", + "8" + ], + [ + "18∼20", + "2", + "4", + "8" + ], + [ + "22∼24", + "3", + "4", + "12" + ], + [ + "26∼28", + "3", + "4", + "12" + ], + [ + "30∼32", + "4", + "4", + "16" + ], + [ + "34∼36", + "4", + "4", + "16" + ], + [ + "38∼40", + "4", + "4", + "16" + ], + [ + "42∼44", + "5", + "4", + "20" + ], + [ + "46∼48", + "5", + "4", + "20" + ], + [ + "50∼52", + "6", + "4", + "24" + ], + [ + "54∼56", + "6", + "4", + "24" + ], + [ + "58∼60", + "6", + "4", + "24" + ], + [ + "62∼64", + "7", + "4", + "28" + ], + [ + "66∼68", + "7", + "4", + "28" + ], + [ + "70∼72", + "8", + "4", + "32" + ], + [ + "74∼76", + "8", + "4", + "32" + ], + [ + "78∼80", + "8", + "4", + "32" + ], + [ + "82∼84", + "9", + "4", + "36" + ], + [ + "86∼88", + "9", + "4", + "36" + ], + [ + "90∼92", + "10", + "4", + "40" + ], + [ + "94∼96", + "11", + "4", + "44" + ], + [ + "98∼100", + "11", + "4", + "44" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-02-05", + "number": "8-2-5", + "name": "푸사리움가지마름병", + "level": 3, + "parent_code": "FP-08-02", + "sort_order": 58624, + "tables": [ + { + "pum_table_id": "F0218", + "section": "8-2-5. 푸사리움가지마름병", + "source_line": 4309, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "테부코나졸 유탁제 25%" + ], + "condition_note": [ + "선정약제", + "원액 주입량", + "비고" + ], + "raw_row": [ + [ + "테부코나졸 유탁제 25%", + "0.5㎖/㎝", + "" + ] + ] + }, + { + "pum_table_id": "F0219", + "section": "8-2-5. 푸사리움가지마름병", + "source_line": 4320, + "pum_form": "requirement", + "form_basis": "값 단위 '(개)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10∼12", + "14∼16", + "18∼20", + "22∼24", + "26∼28", + "30∼32", + "34∼36", + "38∼40", + "42∼44", + "46∼48", + "50∼52", + "54∼56", + "58∼60", + "62∼64", + "66∼68", + "70∼72", + "74∼76", + "78∼80", + "82∼84", + "86∼88", + "90∼92", + "94∼96", + "98∼100" + ], + "condition_note": [ + "가슴높이지름 (㎝)", + "천공수 (개)", + "천공당 주입량 (㎖)", + "본당 주입량 (㎖)" + ], + "raw_row": [ + [ + "10∼12", + "2", + "4", + "8" + ], + [ + "14∼16", + "2", + "4", + "8" + ], + [ + "18∼20", + "3", + "4", + "12" + ], + [ + "22∼24", + "3", + "4", + "12" + ], + [ + "26∼28", + "4", + "4", + "16" + ], + [ + "30∼32", + "4", + "4", + "16" + ], + [ + "34∼36", + "5", + "4", + "20" + ], + [ + "38∼40", + "5", + "4", + "20" + ], + [ + "42∼44", + "6", + "4", + "24" + ], + [ + "46∼48", + "6", + "4", + "24" + ], + [ + "50∼52", + "7", + "4", + "28" + ], + [ + "54∼56", + "7", + "4", + "28" + ], + [ + "58∼60", + "8", + "4", + "32" + ], + [ + "62∼64", + "8", + "4", + "32" + ], + [ + "66∼68", + "9", + "4", + "36" + ], + [ + "70∼72", + "9", + "4", + "36" + ], + [ + "74∼76", + "10", + "4", + "40" + ], + [ + "78∼80", + "10", + "4", + "40" + ], + [ + "82∼84", + "11", + "4", + "44" + ], + [ + "86∼88", + "11", + "4", + "44" + ], + [ + "90∼92", + "12", + "4", + "48" + ], + [ + "94∼96", + "12", + "4", + "48" + ], + [ + "98∼100", + "13", + "4", + "52" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-03", + "number": "8-3", + "name": "소각, 매몰, 훈증, 박피", + "level": 2, + "parent_code": "FP-08", + "sort_order": 58880, + "tables": [ + { + "pum_table_id": "F0220", + "section": "8-3. 소각, 매몰, 훈증, 박피", + "source_line": 4348, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "6", + "8", + "10", + "12", + "14", + "16", + "18", + "20", + "22", + "24", + "26", + "28", + "30", + "32", + "34", + "36", + "38", + "40", + "42", + "44", + "46", + "48", + "50이상" + ], + "condition_note": [ + "경급별 (㎝)", + "벌목조재 (㎥/인)", + "소운반 (㎥/인)", + "무더기 훈증 (RM/인)", + "그루터기 훈증 (본/인)", + "소각 (㎥/인)", + "매몰 (㎥/인)", + "그루터기 박피 (본/인)", + "원목 박피 (본/인)" + ], + "raw_row": [ + [ + "6", + "1.13", + "5.41", + "14.40", + "169.5", + "7.22", + "1.50", + "100.0", + "19.5" + ], + [ + "8", + "1.18", + "5.50", + "14.73", + "151.5", + "7.39", + "1.50", + "100.0", + "19.5" + ], + [ + "10", + "1.24", + "5.59", + "15.05", + "137.0", + "7.56", + "1.56", + "100.0", + "19.5" + ], + [ + "12", + "1.26", + "5.68", + "15.38", + "90.9", + "7.72", + "1.59", + "95.2", + "19.5" + ], + [ + "14", + "1.29", + "5.77", + "15.71", + "68.0", + "7.89", + "1.66", + "95.2", + "15.2" + ], + [ + "16", + "1.32", + "5.86", + "16.04", + "54.6", + "8.06", + "1.68", + "95.2", + "12.5" + ], + [ + "18", + "1.34", + "5.95", + "16.37", + "45.5", + "8.22", + "1.70", + "95.2", + "9.7" + ], + [ + "20", + "1.51", + "6.04", + "16.70", + "39.1", + "8.39", + "1.70", + "95.2", + "9.2" + ], + [ + "22", + "1.60", + "6.13", + "17.03", + "34.1", + "8.56", + "1.72", + "46.3", + "7.4" + ], + [ + "24", + "1.74", + "6.22", + "17.35", + "30.4", + "8.72", + "1.74", + "46.3", + "6.5" + ], + [ + "26", + "1.84", + "6.31", + "17.68", + "24.9", + "8.89", + "1.76", + "46.3", + "6.0" + ], + [ + "28", + "1.94", + "6.40", + "18.01", + "21.0", + "9.06", + "1.77", + "46.3", + "5.8" + ], + [ + "30", + "2.05", + "6.49", + "18.34", + "18.2", + "9.23", + "1.78", + "46.3", + "5.5" + ], + [ + "32", + "2.13", + "6.57", + "18.67", + "16.1", + "9.39", + "1.79", + "41.3", + "4.8" + ], + [ + "34", + "2.21", + "6.66", + "19.00", + "14.4", + "9.56", + "1.79", + "41.3", + "4.2" + ], + [ + "36", + "2.29", + "6.75", + "19.32", + "13.7", + "9.73", + "1.79", + "41.3", + "3.9" + ], + [ + "38", + "2.35", + "6.84", + "19.65", + "10.5", + "9.89", + "1.79", + "41.3", + "3.5" + ], + [ + "40", + "2.48", + "6.93", + "19.98", + "9.1", + "10.06", + "1.83", + "41.3", + "3.1" + ], + [ + "42", + "2.55", + "7.02", + "20.31", + "8.1", + "10.23", + "1.83", + "36.0", + "2.8" + ], + [ + "44", + "2.62", + "7.11", + "20.64", + "7.3", + "10.40", + "1.83", + "36.0", + "2.5" + ], + [ + "46", + "2.68", + "7.20", + "20.97", + "6.6", + "10.56", + "1.83", + "36.0", + "2.3" + ], + [ + "48", + "2.74", + "7.29", + "21.29", + "6.0", + "10.73", + "1.84", + "36.0", + "2.2" + ], + [ + "50이상", + "2.80", + "7.38", + "21.62", + "5.5", + "10.90", + "1.84", + "36.0", + "2.0" + ] + ] + }, + { + "pum_table_id": "F0221", + "section": "8-3. 소각, 매몰, 훈증, 박피", + "source_line": 4386, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "4cm", + "6", + "18", + "10", + "12", + "14", + "16", + "20", + "22", + "24", + "26", + "28", + "30", + "평균" + ], + "condition_note": [ + "수종 가슴 높이 지름", + "소나무", + "낙엽송", + "참나무", + "산오리 나무", + "들오리 나무", + "이태리 포플러", + "아까시 나무" + ], + "raw_row": [ + [ + "4cm", + "", + "32", + "", + "27", + "37", + "35", + "24" + ], + [ + "6", + "", + "28", + "", + "24", + "42", + "29", + "23" + ], + [ + "18", + "41", + "24", + "25", + "23", + "45", + "25", + "23" + ], + [ + "10", + "35", + "22", + "28", + "22", + "47", + "22", + "22" + ], + [ + "12", + "32", + "21", + "30", + "21", + "49", + "20", + "22" + ], + [ + "14", + "29", + "20", + "32", + "20", + "50", + "19", + "21" + ], + [ + "16", + "27", + "19", + "33", + "20", + "51", + "17", + "21" + ], + [ + "18", + "25", + "19", + "34", + "20", + "52", + "17", + "21" + ], + [ + "20", + "23", + "18", + "35", + "19", + "53", + "16", + "21" + ], + [ + "22", + "22", + "17", + "36", + "", + "", + "15", + "21" + ], + [ + "24", + "21", + "17", + "36", + "", + "", + "15", + "21" + ], + [ + "26", + "20", + "16", + "37", + "", + "", + "14", + "21" + ], + [ + "28", + "19", + "16", + "37", + "", + "", + "14", + "21" + ], + [ + "30", + "18", + "16", + "37", + "", + "", + "13", + "21" + ], + [ + "평균", + "26", + "20", + "33", + "22", + "47", + "21", + "22" + ] + ] + }, + { + "pum_table_id": "F0222", + "section": "8-3. 소각, 매몰, 훈증, 박피", + "source_line": 4406, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "중부지방소나무" + ], + "condition_note": [ + "기 준", + "적용대상 수종", + "비율(수간재적의 %)", + "비 고" + ], + "raw_row": [ + [ + "중부지방소나무", + "소나무, 곰솔, 잣나무", + "25", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-04", + "number": "8-4", + "name": "끈끈이 롤 트랩", + "level": 2, + "parent_code": "FP-08", + "sort_order": 59136, + "tables": [ + { + "pum_table_id": "F0223", + "section": "8-4. 끈끈이 롤 트랩", + "source_line": 4420, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "8롤 미만", + "롤트랩 설치", + "롤트랩 제거" + ], + "condition_note": [ + "구 분", + "단위", + "작업량(롤트랩 사용량)", + "소요자재", + "적용인부" + ], + "raw_row": [ + [ + "8롤 미만", + "8롤∼11롤 미만", + "11롤 이상", + "임업용 테이프", + "", + "", + "" + ], + [ + "롤트랩 설치", + "ha", + "2.5", + "3.5", + "4.5", + "4.0m/본", + "보통인부" + ], + [ + "롤트랩 제거", + "ha", + "0.7", + "1.0", + "1.3", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-05", + "number": "8-5", + "name": "페르몬 유인트랩", + "level": 2, + "parent_code": "FP-08", + "sort_order": 59392, + "tables": [ + { + "pum_table_id": "F0224", + "section": "8-5. 페르몬 유인트랩", + "source_line": 4434, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "트랩설치", + "트랩통수거 및 교체", + "트랩철거" + ], + "condition_note": [ + "구 분", + "소요인력(조/ha당)", + "적용인부" + ], + "raw_row": [ + [ + "트랩설치", + "0.25", + "2인 1조 (보통인부)" + ], + [ + "트랩통수거 및 교체", + "0.16", + "" + ], + [ + "트랩철거", + "0.20", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-06", + "number": "8-6", + "name": "약제살포", + "level": 2, + "parent_code": "FP-08", + "sort_order": 59648, + "tables": [] + }, + { + "work_item_code": "FP-08-06-01", + "number": "8-6-1", + "name": "유인헬기방제", + "level": 3, + "parent_code": "FP-08-06", + "sort_order": 59904, + "tables": [ + { + "pum_table_id": "F0225", + "section": "8-6-1. 유인헬기방제", + "source_line": 4449, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 160.0, + "basis_unit": "ha", + "basis_source": "표 안", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "소형헬기 (160ha당)", + "사전조사", + "헬기장정리", + "깃발설치", + "양수기설치", + "약제조제", + "대형헬기 (400ha당)" + ], + "condition_note": [ + "구 분", + "항 목", + "소요인력(인)" + ], + "raw_row": [ + [ + "특별인부", + "보통인부", + "", + "" + ], + [ + "소형헬기 (160ha당)", + "계", + "0.8", + "8.2" + ], + [ + "사전조사", + "0.8", + "-", + "" + ], + [ + "헬기장정리", + "-", + "1.0", + "" + ], + [ + "깃발설치", + "-", + "3.2", + "" + ], + [ + "양수기설치", + "-", + "3.0", + "" + ], + [ + "약제조제", + "-", + "1.0", + "" + ], + [ + "대형헬기 (400ha당)", + "계", + "2.1", + "13.4" + ], + [ + "사전조사", + "2.1", + "-", + "" + ], + [ + "헬기장정리", + "-", + "1.0", + "" + ], + [ + "깃발설치", + "-", + "8.4", + "" + ], + [ + "양수기설치", + "-", + "3.0", + "" + ], + [ + "약제조제", + "-", + "1.0", + "" + ] + ] + }, + { + "pum_table_id": "F0226", + "section": "8-6-1. 유인헬기방제", + "source_line": 4471, + "pum_form": "requirement", + "form_basis": "'㏊당'", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "산림항공방제", + "유인헬기", + "16배", + "30배", + "50배", + "60배", + "80배", + "100배" + ], + "condition_note": [ + "구 분", + "희석배수", + "1㏊당 희석약제 살포량" + ], + "raw_row": [ + [ + "산림항공방제", + "약제 살포량", + "원액량", + "" + ], + [ + "유인헬기", + "8배", + "50", + "6.3" + ], + [ + "16배", + "3.1", + "", + "" + ], + [ + "30배", + "1.7", + "", + "" + ], + [ + "50배", + "1.0", + "", + "" + ], + [ + "60배", + "0.8", + "", + "" + ], + [ + "80배", + "0.6", + "", + "" + ], + [ + "100배", + "0.5", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0227", + "section": "8-6-1. 유인헬기방제", + "source_line": 4485, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소형헬기 (AS350)", + "16배", + "30배", + "50배", + "60배", + "80배", + "100배", + "소형헬기 (Bell206)", + "대형헬기 (KA-32)" + ], + "condition_note": [ + "구 분", + "물탱크 용량", + "희석배수", + "유효 살포량/1회", + "원액량" + ], + "raw_row": [ + [ + "소형헬기 (AS350)", + "800", + "8배", + "500", + "62.5" + ], + [ + "16배", + "31.3", + "", + "", + "" + ], + [ + "30배", + "16.7", + "", + "", + "" + ], + [ + "50배", + "10.0", + "", + "", + "" + ], + [ + "60배", + "8.3", + "", + "", + "" + ], + [ + "80배", + "6.3", + "", + "", + "" + ], + [ + "100배", + "5.0", + "", + "", + "" + ], + [ + "소형헬기 (Bell206)", + "600", + "8배", + "400", + "50.0" + ], + [ + "16배", + "25.0", + "", + "", + "" + ], + [ + "30배", + "13.3", + "", + "", + "" + ], + [ + "50배", + "8.0", + "", + "", + "" + ], + [ + "60배", + "6.7", + "", + "", + "" + ], + [ + "80배", + "5.0", + "", + "", + "" + ], + [ + "100배", + "4.0", + "", + "", + "" + ], + [ + "대형헬기 (KA-32)", + "3,000", + "8배", + "2,000", + "250.0" + ], + [ + "16배", + "125.0", + "", + "", + "" + ], + [ + "30배", + "66.7", + "", + "", + "" + ], + [ + "50배", + "40.0", + "", + "", + "" + ], + [ + "60배", + "33.3", + "", + "", + "" + ], + [ + "80배", + "25.0", + "", + "", + "" + ], + [ + "100배", + "20.0", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-06-02", + "number": "8-6-2", + "name": "드론방제", + "level": 3, + "parent_code": "FP-08-06", + "sort_order": 60160, + "tables": [ + { + "pum_table_id": "F0228", + "section": "8-6-2. 드론방제", + "source_line": 4516, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "인", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "드론조종자 (건설기계 조종원)", + "무인헬기 (ha당)", + "이착륙장 정리(1개소/4ha)", + "취수 및 약제조제", + "약제살포", + "주유 및 약제충전 기체정비", + "비행준비" + ], + "condition_note": [ + "구 분", + "항 목", + "소요인력(인)" + ], + "raw_row": [ + [ + "드론조종자 (건설기계 조종원)", + "부조종자 (건설기계 조종원)", + "특별인부 (신호수)", + "보통인부", + "", + "" + ], + [ + "무인헬기 (ha당)", + "계", + "0.1226", + "0.2352", + "0.1193", + "0.1330" + ], + [ + "이착륙장 정리(1개소/4ha)", + "-", + "", + "", + "0.0137", + "" + ], + [ + "취수 및 약제조제", + "-", + "", + "0.0067", + "0.0067", + "" + ], + [ + "약제살포", + "0.1126", + "0.2252", + "", + "", + "" + ], + [ + "주유 및 약제충전 기체정비", + "", + "", + "0.1126", + "0.1126", + "" + ], + [ + "비행준비", + "0.010", + "0.010", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0229", + "section": "8-6-2. 드론방제", + "source_line": 4536, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "인", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "드론조종자 (건설기계 조종원)", + "멀티콥터 (ha당)", + "이착륙장 정리(1개소/4ha)", + "취수 및 약제조제", + "약제살포", + "기체 및 약제충전 기체정비", + "비행준비" + ], + "condition_note": [ + "구 분", + "항 목", + "소요인력(인)" + ], + "raw_row": [ + [ + "드론조종자 (건설기계 조종원)", + "부조종자 (건설기계 조종원)", + "특별인부 (신호수)", + "보통인부", + "", + "" + ], + [ + "멀티콥터 (ha당)", + "계", + "0.2132", + "0.3148", + "0.2099", + "0.2236" + ], + [ + "이착륙장 정리(1개소/4ha)", + "-", + "-", + "-", + "0.0137", + "" + ], + [ + "취수 및 약제조제", + "-", + "-", + "0.0067", + "0.0067", + "" + ], + [ + "약제살포", + "0.2032", + "0.3048", + "-", + "-", + "" + ], + [ + "기체 및 약제충전 기체정비", + "-", + "-", + "0.2032", + "0.2032", + "" + ], + [ + "비행준비", + "0.01", + "0.01", + "-", + "-", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-06-03", + "number": "8-6-3", + "name": "지상방제", + "level": 3, + "parent_code": "FP-08-06", + "sort_order": 60416, + "tables": [ + { + "pum_table_id": "F0230", + "section": "8-6-3. 지상방제", + "source_line": 4557, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "인", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "차량살포 (동력분무기)", + "사전조사", + "운전", + "물주입, 살포" + ], + "condition_note": [ + "구 분", + "항 목", + "소요인력" + ], + "raw_row": [ + [ + "특별인부", + "보통인부", + "", + "" + ], + [ + "차량살포 (동력분무기)", + "계", + "1.05", + "2.00" + ], + [ + "사전조사", + "0.05", + "-", + "" + ], + [ + "운전", + "1.00", + "-", + "" + ], + [ + "물주입, 살포", + "-", + "2.00", + "" + ] + ] + }, + { + "pum_table_id": "F0231", + "section": "8-6-3. 지상방제", + "source_line": 4569, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "250배", + "500배", + "1,000배", + "1,500배", + "2,000배", + "2,500배", + "3,000배", + "4,000배", + "5,000배" + ], + "condition_note": [ + "물의양 희석 배수", + "10ℓ (0.5말)", + "20ℓ (1말)", + "50ℓ (2.5말)", + "100ℓ (5말)", + "200ℓ (10말)", + "400ℓ (20말)", + "500ℓ (25말)", + "600ℓ (30말)" + ], + "raw_row": [ + [ + "250배", + "40.0", + "80.0", + "200.0", + "400.0", + "800.0", + "1,600.0", + "2,000.0", + "2,400.0" + ], + [ + "500배", + "20.0", + "40.0", + "100.0", + "200.0", + "400.0", + "800.0", + "1,000.0", + "1,200.0" + ], + [ + "1,000배", + "10.0", + "20.0", + "50.0", + "100.0", + "200.0", + "400.0", + "500.0", + "600.0" + ], + [ + "1,500배", + "6.7", + "13.3", + "33.3", + "66.7", + "133.3", + "266.7", + "333.3", + "400.0" + ], + [ + "2,000배", + "5.0", + "10.0", + "25.0", + "50.0", + "100.0", + "200.0", + "250.0", + "300.0" + ], + [ + "2,500배", + "4.0", + "8.0", + "20.0", + "40.0", + "80.0", + "160.0", + "200.0", + "240.0" + ], + [ + "3,000배", + "3.3", + "6.7", + "16.7", + "33.3", + "66.7", + "133.3", + "166.7", + "200.0" + ], + [ + "4,000배", + "2.5", + "5.0", + "12.5", + "25.0", + "50.0", + "100.0", + "125.0", + "150.0" + ], + [ + "5,000배", + "2.0", + "4.0", + "10.0", + "20.0", + "40.0", + "80.0", + "100.0", + "120.0" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-07", + "number": "8-7", + "name": "방제 실행 등록", + "level": 2, + "parent_code": "FP-08", + "sort_order": 60672, + "tables": [ + { + "pum_table_id": "F0232", + "section": "8-7. 방제 실행 등록", + "source_line": 4583, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "방제실행 등록" + ], + "condition_note": [ + "구 분", + "소요인력(인)", + "적용인부" + ], + "raw_row": [ + [ + "방제실행 등록", + "0.005", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-08", + "number": "8-8", + "name": "훈증더미 제거", + "level": 2, + "parent_code": "FP-08", + "sort_order": 60928, + "tables": [] + }, + { + "work_item_code": "FP-08-08-01", + "number": "8-8-1", + "name": "인력 제거", + "level": 3, + "parent_code": "FP-08-08", + "sort_order": 61184, + "tables": [ + { + "pum_table_id": "F0233", + "section": "8-8-1. 인력 제거", + "source_line": 4595, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10m이하", + "1.0RM 이하", + "1.1~1.5RM", + "1.6~2.0RM" + ], + "condition_note": [ + "층적부피", + "수집거리", + "적용인부" + ], + "raw_row": [ + [ + "10m이하", + "11~20m", + "21~30m", + "31~40m", + "41~50m", + "", + "" + ], + [ + "1.0RM 이하", + "0.10", + "0.16", + "0.19", + "0.24", + "0.28", + "보통인부" + ], + [ + "1.1~1.5RM", + "0.19", + "0.29", + "0.35", + "0.45", + "0.52", + "" + ], + [ + "1.6~2.0RM", + "0.27", + "0.40", + "0.49", + "0.63", + "0.72", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-08-02", + "number": "8-8-2", + "name": "기계장비 제거", + "level": 3, + "parent_code": "FP-08-08", + "sort_order": 61440, + "tables": [ + { + "pum_table_id": "F0234", + "section": "8-8-2. 기계장비 제거", + "source_line": 4611, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "ha", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "10개 이하", + "굴 삭 기 우드그랩", + "인 력" + ], + "condition_note": [ + "구 분", + "㏊당 훈증더미 갯수", + "비 고" + ], + "raw_row": [ + [ + "10개 이하", + "11~20개", + "21~40개", + "41~60개", + "", + "" + ], + [ + "굴 삭 기 우드그랩", + "7시간", + "7.5시간", + "8시간", + "9시간", + "굴착기 규격 0.2㎥ 기준" + ], + [ + "인 력", + "1.0인", + "1.5인", + "2.0인", + "2.5인", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-09", + "number": "8-9", + "name": "잔가지줍기", + "level": 2, + "parent_code": "FP-08", + "sort_order": 61696, + "tables": [ + { + "pum_table_id": "F0235", + "section": "8-9. 잔가지줍기", + "source_line": 4623, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "잔가지 줍기" + ], + "condition_note": [ + "구 분", + "소요인력(인/본당)", + "적용인부" + ], + "raw_row": [ + [ + "잔가지 줍기", + "0.016", + "보통인부" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-10", + "number": "8-10", + "name": "그물망 피복", + "level": 2, + "parent_code": "FP-08", + "sort_order": 61952, + "tables": [ + { + "pum_table_id": "F0236", + "section": "8-10. 그물망 피복", + "source_line": 4631, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "벌목부", + "1㎥", + "벌목조재", + "피복작업", + "2㎥" + ], + "condition_note": [ + "입목재적", + "항 목", + "소요인력(인)" + ], + "raw_row": [ + [ + "벌목부", + "보통인부", + "", + "" + ], + [ + "1㎥", + "계", + "0.75", + "0.23" + ], + [ + "벌목조재", + "0.75", + "-", + "" + ], + [ + "피복작업", + "-", + "0.23", + "" + ], + [ + "2㎥", + "계", + "1.50", + "0.46" + ], + [ + "벌목조재", + "1.50", + "-", + "" + ], + [ + "피복작업", + "-", + "0.46", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-08-11", + "number": "8-11", + "name": "이동식 임목 파쇄", + "level": 2, + "parent_code": "FP-08", + "sort_order": 62208, + "tables": [ + { + "pum_table_id": "F0237", + "section": "8-11. 이동식 임목 파쇄", + "source_line": 4649, + "pum_form": "productivity", + "form_basis": "본문 '㎥/hr'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "이동식 임목 파쇄기" + ], + "condition_note": [ + "구 분", + "규 격", + "작업량", + "비 고" + ], + "raw_row": [ + [ + "이동식 임목 파쇄기", + "93.25KW=125HP", + "Q = 3.5 ㎥/hr", + "∙ 잡재료 : 주연료비의 16% 적용 ∙ 소모품비(파쇄기날) : 0.00125개/hr" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09", + "number": "9", + "name": "토공", + "level": 1, + "parent_code": null, + "sort_order": 62464, + "tables": [] + }, + { + "work_item_code": "FP-09-01", + "number": "9-1", + "name": "굴착", + "level": 2, + "parent_code": "FP-09", + "sort_order": 62720, + "tables": [] + }, + { + "work_item_code": "FP-09-02", + "number": "9-2", + "name": "노선 굴진 보조원", + "level": 2, + "parent_code": "FP-09", + "sort_order": 62976, + "tables": [ + { + "pum_table_id": "F0238", + "section": "9-2. 노선 굴진 보조원", + "source_line": 4675, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부" + ], + "condition_note": [ + "구 분", + "횡단경사 60% 미만", + "횡단경사 60% 이상" + ], + "raw_row": [ + [ + "특별인부", + "5인/km", + "10인/km" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-03", + "number": "9-3", + "name": "토사깍기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 63232, + "tables": [] + }, + { + "work_item_code": "FP-09-03-01", + "number": "9-3-1", + "name": "인력", + "level": 3, + "parent_code": "FP-09-03", + "sort_order": 63488, + "tables": [ + { + "pum_table_id": "F0239", + "section": "9-3-1. 인력", + "source_line": 4685, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부(인)" + ], + "condition_note": [ + "종류 직종", + "보통토사", + "경질토사, 고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사", + "비 고" + ], + "raw_row": [ + [ + "보통인부(인)", + "0.16", + "0.22", + "0.39", + "대량일 때는 토질조사에 의하여 분류할 것" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-03-02", + "number": "9-3-2", + "name": "기계", + "level": 3, + "parent_code": "FP-09-03", + "sort_order": 63744, + "tables": [ + { + "pum_table_id": "F0240", + "section": "9-3-2. 기계", + "source_line": 4696, + "pum_form": "coefficient", + "form_basis": "행 키가 시공능력 공식 기호뿐 ['E', 'K', 'f', '㎝(sec)']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.30" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": true, + "variant_key": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K", + "0.9", + "" + ], + [ + "f", + "1/1.30", + "" + ], + [ + "E", + "0.55∼0.45", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-04", + "number": "9-4", + "name": "암절취", + "level": 2, + "parent_code": "FP-09", + "sort_order": 64000, + "tables": [] + }, + { + "work_item_code": "FP-09-04-01", + "number": "9-4-1", + "name": "암파쇄", + "level": 3, + "parent_code": "FP-09-04", + "sort_order": 64256, + "tables": [ + { + "pum_table_id": "F0241", + "section": "9-4-1. 암파쇄", + "source_line": 4719, + "pum_form": "productivity", + "form_basis": "헤더 '㎥/hr'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "대형브레이커+ 유압식백호우 (무한궤도,0.7㎥)", + "보통암", + "경 암" + ], + "condition_note": [ + "적용기계", + "암의 종류", + "작업능력(㎥/hr)", + "치즐소모량(본/hr)", + "비 고" + ], + "raw_row": [ + [ + "대형브레이커+ 유압식백호우 (무한궤도,0.7㎥)", + "연 암", + "5.0", + "0.006", + "" + ], + [ + "보통암", + "3.4", + "0.02", + "", + "" + ], + [ + "경 암", + "2.6", + "0.03", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-04-02", + "number": "9-4-2", + "name": "집토", + "level": 3, + "parent_code": "FP-09-04", + "sort_order": 64512, + "tables": [ + { + "pum_table_id": "F0242", + "section": "9-4-2. 집토", + "source_line": 4730, + "pum_form": "coefficient", + "form_basis": "행 키가 시공능력 공식 기호뿐 ['E', 'K', 'f', '㎝(sec)']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.40" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K", + "0.55", + "" + ], + [ + "f", + "1/1.40", + "" + ], + [ + "E", + "0.35", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-05", + "number": "9-5", + "name": "발파암", + "level": 2, + "parent_code": "FP-09", + "sort_order": 64768, + "tables": [] + }, + { + "work_item_code": "FP-09-05-01", + "number": "9-5-1", + "name": "육상, 암석 절취(발파 10%)", + "level": 3, + "parent_code": "FP-09-05", + "sort_order": 65024, + "tables": [ + { + "pum_table_id": "F0243", + "section": "9-5-1. 육상, 암석 절취(발파 10%)", + "source_line": 4746, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "뇌 관", + "비 트", + "착 암 공" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "뇌 관", + "비 트", + "인력", + "착 암 공", + "보통인부", + "장비", + "공기압축기" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "자재", + "폭 약", + "", + "kg", + "0.35", + "잡재료비:주재료의 5%" + ], + [ + "뇌 관", + "", + "개", + "1.0", + "", + "" + ], + [ + "비 트", + "", + "개", + "0.008", + "", + "" + ], + [ + "인력", + "화 약 공", + "", + "인", + "0.041", + "" + ], + [ + "착 암 공", + "", + "인", + "0.041", + "", + "" + ], + [ + "보통인부", + "", + "인", + "0.103", + "", + "" + ], + [ + "장비", + "착 암 기", + "2.7㎥/min", + "hr", + "0.203", + "" + ], + [ + "공기압축기", + "10.3㎥/min", + "hr", + "0.074", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-05-02", + "number": "9-5-2", + "name": "깍기(90%)", + "level": 3, + "parent_code": "FP-09-05", + "sort_order": 65280, + "tables": [ + { + "pum_table_id": "F0244", + "section": "9-5-2. 깎기(90%)", + "source_line": 4759, + "pum_form": "productivity", + "form_basis": "헤더 '㎥/hr'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "연 암", + "보통암", + "경 암" + ], + "condition_note": [ + "암의 종류", + "작업능력(㎥/hr)", + "치즐소모량(본/hr)", + "비 고" + ], + "raw_row": [ + [ + "연 암", + "5.0", + "0.006", + "" + ], + [ + "보통암", + "3.4", + "0.02", + "" + ], + [ + "경 암", + "2.6", + "0.03", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-05-03", + "number": "9-5-3", + "name": "집토", + "level": 3, + "parent_code": "FP-09-05", + "sort_order": 65536, + "tables": [ + { + "pum_table_id": "F0245", + "section": "9-5-3. 집토", + "source_line": 4769, + "pum_form": "coefficient", + "form_basis": "행 키가 시공능력 공식 기호뿐 ['E', 'K', 'f', '㎝(sec)']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.625" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K", + "0.55", + "" + ], + [ + "f", + "1/1.625", + "" + ], + [ + "E", + "0.35", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-06", + "number": "9-6", + "name": "발파암 소할", + "level": 2, + "parent_code": "FP-09", + "sort_order": 65792, + "tables": [] + }, + { + "work_item_code": "FP-09-06-01", + "number": "9-6-1", + "name": "기계소할(15%)", + "level": 3, + "parent_code": "FP-09-06", + "sort_order": 66048, + "tables": [ + { + "pum_table_id": "F0246", + "section": "9-6-1. 기계소할(15%)", + "source_line": 4786, + "pum_form": "productivity", + "form_basis": "헤더 '㎥/hr'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "10.0 (9+11)/2" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "대형브레이커+유압식백호우 (무한궤도,0.7㎥)" + ], + "condition_note": [ + "적용기계", + "작업능력(㎥/hr)", + "치즐소모량(본/hr)" + ], + "raw_row": [ + [ + "대형브레이커+유압식백호우 (무한궤도,0.7㎥)", + "10.0 (9+11)/2", + "0.025" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-07", + "number": "9-7", + "name": "무근콘크리트 깨기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 66304, + "tables": [] + }, + { + "work_item_code": "FP-09-07-01", + "number": "9-7-1", + "name": "T=30cm 미만", + "level": 3, + "parent_code": "FP-09-07", + "sort_order": 66560, + "tables": [ + { + "pum_table_id": "F0247", + "section": "9-7-1. T=30㎝ 미만", + "source_line": 4803, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "무근콘크리트" + ], + "condition_note": [ + "구 분", + "평균두께", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "무근콘크리트", + "30㎝미만", + "㎥/hr", + "3.3∼5.9", + "대형브레이커치즐 0.01본/hr 보통인부 1인/일" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-07-02", + "number": "9-7-2", + "name": "T=30cm 이상", + "level": 3, + "parent_code": "FP-09-07", + "sort_order": 66816, + "tables": [ + { + "pum_table_id": "F0249", + "section": "9-7-2. T=30㎝ 이상", + "source_line": 4834, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "무근콘크리트" + ], + "condition_note": [ + "구 분", + "평균두께", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "무근콘크리트", + "30㎝이상", + "㎥/hr", + "2.6∼4.6", + "대형브레이커치즐 0.01본/hr 보통인부 1인/일" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-08", + "number": "9-8", + "name": "철근콘크리트 깨기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 67072, + "tables": [] + }, + { + "work_item_code": "FP-09-08-01", + "number": "9-8-1", + "name": "T=30cm 미만", + "level": 3, + "parent_code": "FP-09-08", + "sort_order": 67328, + "tables": [ + { + "pum_table_id": "F0250", + "section": "9-8-1. T=30㎝ 미만", + "source_line": 4851, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "철근콘크리트" + ], + "condition_note": [ + "구 분", + "평균두께", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "철근콘크리트", + "30㎝미만", + "㎥/hr", + "1.6∼3.3", + "대형브레이커치즐 0.01본/hr 보통인부 1인/일" + ] + ] + }, + { + "pum_table_id": "F0251", + "section": "9-8-1. T=30㎝ 미만", + "source_line": 4860, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인력", + "보통인부", + "자재", + "산소" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력", + "용접공", + "인", + "0.02", + "" + ], + [ + "보통인부", + "인", + "0.08", + "", + "" + ], + [ + "자재", + "아세틸렌", + "kg", + "0.05", + "" + ], + [ + "산소", + "ℓ", + "135", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-08-02", + "number": "9-8-2", + "name": "T=30cm 이상", + "level": 3, + "parent_code": "FP-09-08", + "sort_order": 67584, + "tables": [ + { + "pum_table_id": "F0252", + "section": "9-8-2. T=30㎝ 이상", + "source_line": 4878, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "철근콘크리트" + ], + "condition_note": [ + "구 분", + "평균두께", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "철근콘크리트", + "30㎝이상", + "㎥/hr", + "1.4∼2.7", + "대형브레이커치즐 0.01본/hr 보통인부 1인/일" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-09", + "number": "9-9", + "name": "석축헐기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 67840, + "tables": [ + { + "pum_table_id": "F0253", + "section": "9-9. 석축헐기", + "source_line": 4896, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "메쌓기", + "뒷길이60㎝이상", + "찰쌓기" + ], + "condition_note": [ + "구 분", + "평균뒷길이", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "메쌓기", + "뒷길이60㎝미만", + "인/㎡", + "0.2", + "인력작업 적용" + ], + [ + "뒷길이60㎝이상", + "인/㎡", + "0.3", + "", + "" + ], + [ + "찰쌓기", + "", + "인/㎡", + "0.6", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-10", + "number": "9-10", + "name": "기존포장 깨기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 68096, + "tables": [] + }, + { + "work_item_code": "FP-09-10-01", + "number": "9-10-1", + "name": "콘크리트", + "level": 3, + "parent_code": "FP-09-10", + "sort_order": 68352, + "tables": [ + { + "pum_table_id": "F0254", + "section": "9-10-1. 콘크리트", + "source_line": 4912, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트" + ], + "condition_note": [ + "구 분", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "콘크리트", + "㎥/hr", + "3.3∼5.9", + "대형브레이커치즐 0.01본/hr 보통인부 1인/일" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-10-02", + "number": "9-10-2", + "name": "아스팔트", + "level": 3, + "parent_code": "FP-09-10", + "sort_order": 68608, + "tables": [ + { + "pum_table_id": "F0255", + "section": "9-10-2. 아스팔트", + "source_line": 4929, + "pum_form": "productivity", + "form_basis": "헤더 '작업능력'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "아스팔트" + ], + "condition_note": [ + "구 분", + "단위", + "작업능력", + "비 고" + ], + "raw_row": [ + [ + "아스팔트", + "㎥/hr", + "16.0", + "대형브레이커치즐 0.01본/hr 보통인부 1인/일" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-11", + "number": "9-11", + "name": "포장절단", + "level": 2, + "parent_code": "FP-09", + "sort_order": 68864, + "tables": [] + }, + { + "work_item_code": "FP-09-11-01", + "number": "9-11-1", + "name": "콘크리트(기계)", + "level": 3, + "parent_code": "FP-09-11", + "sort_order": 69120, + "tables": [ + { + "pum_table_id": "F0256", + "section": "9-11-1. 콘크리트(기계)", + "source_line": 4946, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력", + "보통인부", + "장비", + "자재", + "잡자재", + "경비" + ], + "condition_note": [ + "구 분", + "규 격", + "단 위", + "적용", + "비 고" + ], + "raw_row": [ + [ + "인력", + "특별인부", + "", + "인", + "1", + "" + ], + [ + "보통인부", + "", + "인", + "2", + "", + "" + ], + [ + "장비", + "콘크리트커트", + "320~400mm", + "m", + "350", + "절단깊이 50-75mm" + ], + [ + "자재", + "브레이드", + "320~400mm", + "개", + "1.085", + "0.31개/100m*350m" + ], + [ + "잡자재", + "인건비의", + "%", + "5", + "", + "" + ], + [ + "경비", + "물운반", + "30ℓ/m", + "ℓ", + "", + "운반방법별 적용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-12", + "number": "9-12", + "name": "측구터파기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 69376, + "tables": [] + }, + { + "work_item_code": "FP-09-12-01", + "number": "9-12-1", + "name": "토사", + "level": 3, + "parent_code": "FP-09-12", + "sort_order": 69632, + "tables": [ + { + "pum_table_id": "F0258", + "section": "9-12-1. 토사", + "source_line": 4978, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.2+0.26)/2", + "1/1.3", + "(0.7+0.6)/2-0.05" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력(10%)", + "장비(90%)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력(10%)", + "보통인부(인)", + "0.23", + "(0.2+0.26)/2", + "" + ], + [ + "장비(90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.9", + "" + ], + [ + "f", + "0.77", + "1/1.3", + "", + "" + ], + [ + "E", + "0.60", + "(0.7+0.6)/2-0.05", + "", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-12-02", + "number": "9-12-2", + "name": "암절취", + "level": 3, + "parent_code": "FP-09-12", + "sort_order": 69888, + "tables": [ + { + "pum_table_id": "F0259", + "section": "9-12-2. 암절취", + "source_line": 4994, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "1/1.35", + "(0.65+0.45)/2-0.05" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모(본/hr)", + "유압식백호우 (무한궤도,0.7㎥)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "1.6", + "" + ], + [ + "보통인부(인)", + "0.8", + "", + "" + ], + [ + "장비 (90%)", + "대형브레이커(㎥/hr)", + "3.5", + "Q=(3.2+3.8)/2 (연암평균치 적용)" + ], + [ + "치즐소모(본/hr)", + "0.006", + "", + "" + ], + [ + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.55", + "" + ], + [ + "f", + "0.74", + "1/1.35", + "" + ], + [ + "E", + "0.50", + "(0.65+0.45)/2-0.05", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-12-03", + "number": "9-12-3", + "name": "발파암", + "level": 3, + "parent_code": "FP-09-12", + "sort_order": 70144, + "tables": [ + { + "pum_table_id": "F0260", + "section": "9-12-3. 발파암", + "source_line": 5009, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.006+0.02+0.03)/3", + "1/1.625" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "유압식백호우 (무한궤도,0.7㎥)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "2.8", + "" + ], + [ + "보통인부(인)", + "1.266", + "", + "" + ], + [ + "장비 (90%)", + "대형브레이커(㎥/hr)", + "2.6", + "Q=(3.5+2.5+1.8)/3" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "(0.006+0.02+0.03)/3", + "" + ], + [ + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.55", + "" + ], + [ + "f", + "0.62", + "1/1.625", + "" + ], + [ + "E", + "0.40", + "0.45-0.05", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13", + "number": "9-13", + "name": "구조물터파기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 70400, + "tables": [] + }, + { + "work_item_code": "FP-09-13-01", + "number": "9-13-1", + "name": "육상토사(0~1m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 70656, + "tables": [ + { + "pum_table_id": "F0261", + "section": "9-13-1. 육상토사(0~1m)", + "source_line": 5031, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.2+0.26)/2", + "(0.7+0.6)/2-0.05" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "장비 (90%)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.23", + "(0.2+0.26)/2", + "" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.9", + "" + ], + [ + "f", + "0.77", + "", + "", + "" + ], + [ + "E", + "0.60", + "(0.7+0.6)/2-0.05", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-02", + "number": "9-13-2", + "name": "육상토사(1~2m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 70912, + "tables": [ + { + "pum_table_id": "F0262", + "section": "9-13-2. 육상토사(1~2m)", + "source_line": 5045, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.27+0.35)/2" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "장비 (90%)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.31", + "(0.27+0.35)/2" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "육상토사(0~1m)와 동일", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-03", + "number": "9-13-3", + "name": "육상토사(2~3m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 71168, + "tables": [ + { + "pum_table_id": "F0263", + "section": "9-13-3. 육상토사(2~3m)", + "source_line": 5054, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.34+0.44)/2" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "장비 (90%)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.39", + "(0.34+0.44)/2" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "육상토사(0~1m)와 동일", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-04", + "number": "9-13-4", + "name": "용수토사(0~1m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 71424, + "tables": [ + { + "pum_table_id": "F0264", + "section": "9-13-4. 용수토사(0~1m)", + "source_line": 5063, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.55+0.45)/2-0.05" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "장비 (90%)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.345", + "육상토사(0-1m)×1.5배", + "" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.9", + "" + ], + [ + "f", + "0.77", + "", + "", + "" + ], + [ + "E", + "0.45", + "(0.55+0.45)/2-0.05", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-05", + "number": "9-13-5", + "name": "용수토사(1~2m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 71680, + "tables": [ + { + "pum_table_id": "F0265", + "section": "9-13-5. 용수토사(1~2m)", + "source_line": 5075, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "장비 (90%)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.465", + "육상토사(1-2m)×1.5배" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "용수토사(0~1m)와 동일", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-06", + "number": "9-13-6", + "name": "용수토사(2~3m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 71936, + "tables": [ + { + "pum_table_id": "F0266", + "section": "9-13-6. 용수토사(2~3m)", + "source_line": 5084, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "장비 (90%)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.585", + "육상토사(2-3m)×1.5배" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "용수토사(0~1m)와 동일", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-07", + "number": "9-13-7", + "name": "육상 암절취(0~1m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 72192, + "tables": [ + { + "pum_table_id": "F0267", + "section": "9-13-7. 육상 암절취(0~1m)", + "source_line": 5093, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.65+0.45)/2-0.05" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할석공(인)", + "1.6", + "", + "" + ], + [ + "보통인부(인)", + "0.8", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "3.5", + "Q=(3.2+3.8)/2 (연암평균치 적용)" + ], + [ + "치즐소모량(본/hr)", + "0.006", + "", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.55", + "" + ], + [ + "f", + "0.74", + "", + "", + "" + ], + [ + "E", + "0.50", + "(0.65+0.45)/2-0.05", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-08", + "number": "9-13-8", + "name": "육상 암절취(1~2m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 72448, + "tables": [ + { + "pum_table_id": "F0268", + "section": "9-13-8. 육상 암절취(1~2m)", + "source_line": 5108, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "1.8", + "", + "" + ], + [ + "보통인부(인)", + "0.9", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "3.5", + "" + ], + [ + "치즐소모량(본/hr)", + "0.006", + "", + "", + "" + ], + [ + "들어내기", + "유압식백호우 (무한궤도,0.7㎥)", + "육상 암절취(0~1m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-09", + "number": "9-13-9", + "name": "육상 암절취(2~3m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 72704, + "tables": [ + { + "pum_table_id": "F0269", + "section": "9-13-9. 육상 암절취(2~3m)", + "source_line": 5120, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "2.0", + "", + "" + ], + [ + "보통인부(인)", + "1.0", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "3.5", + "" + ], + [ + "치즐소모량(본/hr)", + "0.006", + "", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "육상 암절취(0~1m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-10", + "number": "9-13-10", + "name": "용수 암절취(0~1m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 72960, + "tables": [ + { + "pum_table_id": "F0270", + "section": "9-13-10. 용수 암절취(0~1m)", + "source_line": 5132, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "(0.50+0.35)/2-0.05" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "1.2", + "육상 암절취(0-1m)×1.5배", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "3.5", + "Q=(3.2+3.8)/2 (연암평균치 적용)" + ], + [ + "치즐소모량(본/hr)", + "0.006", + "육상과동일", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.55", + "육상과동일" + ], + [ + "f", + "0.74", + "육상과동일", + "", + "" + ], + [ + "E", + "0.375", + "(0.50+0.35)/2-0.05", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "육상과동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-11", + "number": "9-13-11", + "name": "용수 암절취(1~2m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 73216, + "tables": [ + { + "pum_table_id": "F0271", + "section": "9-13-11. 용수 암절취(1~2m)", + "source_line": 5146, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "1.35", + "육상 암절취(1-2m)×1.5배", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "3.5", + "Q=(3.2+3.8)/2 (연암평균치 적용)" + ], + [ + "치즐소모량(본/hr)", + "0.006", + "육상과동일", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "용수 암절취(0~1m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-12", + "number": "9-13-12", + "name": "용수 암절취(2~3m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 73472, + "tables": [ + { + "pum_table_id": "F0272", + "section": "9-13-12. 용수 암절취(2~3m)", + "source_line": 5157, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "1.5", + "육상 암절취(2-3m)×1.5배", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "3.5", + "Q=(3.2+3.8)/2 (연암평균치 적용)" + ], + [ + "치즐소모량(본/hr)", + "0.006", + "육상과동일", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "용수 암절취(0~1m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-13", + "number": "9-13-13", + "name": "육상 발파암(0~1m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 73728, + "tables": [ + { + "pum_table_id": "F0273", + "section": "9-13-13. 육상 발파암(0~1m)", + "source_line": 5168, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "1/1.625" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "2.8", + "", + "" + ], + [ + "보통인부(인)", + "1.266", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "2.6", + "Q=(3.5+2.5+1.8)×1/3" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.55", + "" + ], + [ + "f", + "0.62", + "1/1.625", + "", + "" + ], + [ + "E", + "0.40", + "0.45-0.05", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-14", + "number": "9-13-14", + "name": "육상 발파암(1~2m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 73984, + "tables": [ + { + "pum_table_id": "F0274", + "section": "9-13-14. 육상 발파암(1~2m)", + "source_line": 5183, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "3.5", + "", + "" + ], + [ + "보통인부(인)", + "1.566", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "2.6", + "" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "육상 발파암(1~2m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-15", + "number": "9-13-15", + "name": "육상 발파암(2~3m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 74240, + "tables": [ + { + "pum_table_id": "F0275", + "section": "9-13-15. 육상 발파암(2~3m)", + "source_line": 5195, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "4.2", + "", + "" + ], + [ + "보통인부(인)", + "1.866", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "2.6", + "" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "육상 발파암(1~2m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-16", + "number": "9-13-16", + "name": "용수 발파암(0~1m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 74496, + "tables": [ + { + "pum_table_id": "F0276", + "section": "9-13-16. 용수 발파암(0~1m)", + "source_line": 5207, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "4.20", + "육상 발파암(0-1m)×1.5배", + "" + ], + [ + "보통인부(인)", + "1.899", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "2.6", + "Q=(3.5+2.5+1.8)×1/3" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "육상과 동일", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.55", + "육상과 동일" + ], + [ + "f", + "0.62", + "육상과 동일", + "", + "" + ], + [ + "E", + "0.30", + "0.35-0.05", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "육상과 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-17", + "number": "9-13-17", + "name": "용수 발파암(1~2m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 74752, + "tables": [ + { + "pum_table_id": "F0277", + "section": "9-13-17. 용수 발파암(1~2m)", + "source_line": 5222, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "5.25", + "육상 발파암(1-2m)×1.5배", + "" + ], + [ + "보통인부(인)", + "2.349", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "2.6", + "Q=(3.5+2.5+1.8)×1/3" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "육상과 동일", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "용수 발파암(0~1m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-13-18", + "number": "9-13-18", + "name": "용수 발파암(2~3m)", + "level": 3, + "parent_code": "FP-09-13", + "sort_order": 75008, + "tables": [ + { + "pum_table_id": "F0278", + "section": "9-13-18. 용수 발파암(2~3m)", + "source_line": 5234, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "치즐소모량(본/hr)", + "들어 내기" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "할 석 공(인)", + "6.3", + "육상 발파암(2-3m)×1.5배", + "" + ], + [ + "보통인부(인)", + "2.799", + "", + "", + "" + ], + [ + "장비 (90%)", + "깨기", + "대형브레이커(㎥/hr)", + "2.6", + "Q=(3.5+2.5+1.8)×1/3" + ], + [ + "치즐소모량(본/hr)", + "0.018", + "육상과 동일", + "", + "" + ], + [ + "들어 내기", + "유압식백호우 (무한궤도,0.7㎥)", + "용수 발파암(0~1m)와 동일", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-14", + "number": "9-14", + "name": "되메우기 및 다짐", + "level": 2, + "parent_code": "FP-09", + "sort_order": 75264, + "tables": [] + }, + { + "work_item_code": "FP-09-14-01", + "number": "9-14-1", + "name": "되메우기", + "level": 3, + "parent_code": "FP-09-14", + "sort_order": 75520, + "tables": [ + { + "pum_table_id": "F0279", + "section": "9-14-1. 되메우기", + "source_line": 5248, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력 (10%)", + "장비 (90%)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력 (10%)", + "보통인부(인)", + "0.10", + "", + "" + ], + [ + "장비 (90%)", + "유압식백호우 (무한궤도,0.7㎥)", + "k", + "0.9", + "" + ], + [ + "f", + "0.69", + "f=C/L=0.9/1.3", + "", + "" + ], + [ + "E", + "0.65", + "", + "", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-14-02", + "number": "9-14-2", + "name": "다짐(플레이트 콤펙터)", + "level": 3, + "parent_code": "FP-09-14", + "sort_order": 75776, + "tables": [ + { + "pum_table_id": "F0280", + "section": "9-14-2. 다짐(플레이트 콤펙터)", + "source_line": 5258, + "pum_form": "coefficient", + "form_basis": "행 키가 기호뿐 ['A', 'E', 'H', 'N', 'P', 'f']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "f", + "E" + ], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": true, + "variant_key": [ + "A", + "N", + "H", + "f", + "E", + "P" + ], + "condition_note": [ + "적 용", + "비 고" + ], + "raw_row": [ + [ + "A", + "0.09㎡", + "0.28m×0.33m" + ], + [ + "N", + "36,000회/hr", + "" + ], + [ + "H", + "0.15m", + "" + ], + [ + "f", + "1.0", + "" + ], + [ + "E", + "0.5", + "" + ], + [ + "P", + "57회", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-15", + "number": "9-15", + "name": "표토제거", + "level": 2, + "parent_code": "FP-09", + "sort_order": 76032, + "tables": [] + }, + { + "work_item_code": "FP-09-15-01", + "number": "9-15-1", + "name": "답(畓)구간", + "level": 3, + "parent_code": "FP-09-15", + "sort_order": 76288, + "tables": [ + { + "pum_table_id": "F0281", + "section": "9-15-1. 답(畓)구간", + "source_line": 5273, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.30" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "K(버킷계수)", + "f(토량환산계수)", + "E(작업효율)", + "㎝(1회 사이클시간)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K(버킷계수)", + "0.9", + "" + ], + [ + "f(토량환산계수)", + "1/1.30", + "" + ], + [ + "E(작업효율)", + "0.7", + "" + ], + [ + "㎝(1회 사이클시간)", + "20(135°) sec", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-15-02", + "number": "9-15-2", + "name": "답(畓)외 구간", + "level": 3, + "parent_code": "FP-09-15", + "sort_order": 76544, + "tables": [ + { + "pum_table_id": "F0282", + "section": "9-15-2. 답(畓)외구간", + "source_line": 5290, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.3" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "T(표토두께)", + "L(운반거리)", + "E(작업효율)", + "q(삽날의 용량)", + "q0(거리를 고려하지 않은 삽날의 용량)", + "e(운반거리계수)", + "f(토량환산계수)", + "V1(전진속도)", + "V2(후진속도)", + "t(기어변속시간)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "T(표토두께)", + "0.2m", + "" + ], + [ + "L(운반거리)", + "20m", + "" + ], + [ + "E(작업효율)", + "0.4", + "" + ], + [ + "q(삽날의 용량)", + "q0×e(㎥)", + "" + ], + [ + "q0(거리를 고려하지 않은 삽날의 용량)", + "3.2㎥", + "" + ], + [ + "e(운반거리계수)", + "0.96", + "" + ], + [ + "f(토량환산계수)", + "1/1.3", + "" + ], + [ + "V1(전진속도)", + "40m/분(1단)", + "" + ], + [ + "V2(후진속도)", + "46m/분(1단)", + "" + ], + [ + "t(기어변속시간)", + "0.25분", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-16", + "number": "9-16", + "name": "노체", + "level": 2, + "parent_code": "FP-09", + "sort_order": 76800, + "tables": [] + }, + { + "work_item_code": "FP-09-16-01", + "number": "9-16-1", + "name": "노체포설", + "level": 3, + "parent_code": "FP-09-16", + "sort_order": 77056, + "tables": [ + { + "pum_table_id": "F0283", + "section": "9-16-1. 노체포설", + "source_line": 5314, + "pum_form": "coefficient", + "form_basis": "행 키가 시공능력 공식 기호뿐 ['E', 'K', 'f', '㎝(sec)']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K", + "0.55", + "" + ], + [ + "f", + "1", + "" + ], + [ + "E", + "0.35", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-16-02", + "number": "9-16-2", + "name": "노체다짐", + "level": 3, + "parent_code": "FP-09-16", + "sort_order": 77312, + "tables": [ + { + "pum_table_id": "F0284", + "section": "9-16-2. 노체다짐", + "source_line": 5326, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "V(다짐속도,km/hr)", + "W(롤러 유효폭,m)", + "E(작업효율)", + "D(펴는 흙의 두께,m)", + "f(토량환산계수)", + "N(소요다짐횟수)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "V(다짐속도,km/hr)", + "4", + "" + ], + [ + "W(롤러 유효폭,m)", + "1.9", + "" + ], + [ + "E(작업효율)", + "0.6", + "" + ], + [ + "D(펴는 흙의 두께,m)", + "0.3", + "" + ], + [ + "f(토량환산계수)", + "1.0", + "" + ], + [ + "N(소요다짐횟수)", + "6", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-16-03", + "number": "9-16-3", + "name": "살수", + "level": 3, + "parent_code": "FP-09-16", + "sort_order": 77568, + "tables": [ + { + "pum_table_id": "F0285", + "section": "9-16-3. 살수", + "source_line": 5344, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "흡입준비(t1)", + "운반(t2)", + "흡입(t3)", + "대기((t4)", + "살수(t5)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "흡입준비(t1)", + "5분", + "" + ], + [ + "운반(t2)", + "15 km/hr", + "L/V×2×60" + ], + [ + "흡입(t3)", + "10분", + "" + ], + [ + "대기((t4)", + "5분", + "" + ], + [ + "살수(t5)", + "20분", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-17", + "number": "9-17", + "name": "다짐", + "level": 2, + "parent_code": "FP-09", + "sort_order": 77824, + "tables": [] + }, + { + "work_item_code": "FP-09-17-01", + "number": "9-17-1", + "name": "비탈면 다짐", + "level": 3, + "parent_code": "FP-09-17", + "sort_order": 78080, + "tables": [] + }, + { + "work_item_code": "FP-09-17-02", + "number": "9-17-2", + "name": "비다짐(정지)", + "level": 3, + "parent_code": "FP-09-17", + "sort_order": 78336, + "tables": [ + { + "pum_table_id": "F0286", + "section": "9-17-2. 비다짐(정지)", + "source_line": 5376, + "pum_form": "coefficient", + "form_basis": "행 키가 기호뿐 ['E', 'L', 'V1', 'V2', 'e', 'f', 'q0', 't']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.3" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "E", + "f" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "L", + "E", + "q0", + "e", + "f", + "V1", + "V2", + "t" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "L", + "20m", + "" + ], + [ + "E", + "(0.55-0.1)", + "" + ], + [ + "q0", + "3.2m", + "" + ], + [ + "e", + "0.96", + "" + ], + [ + "f", + "1/1.3", + "" + ], + [ + "V1", + "75m/분(3단)", + "" + ], + [ + "V2", + "98m/분(3단)", + "" + ], + [ + "t", + "0.25분", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-18", + "number": "9-18", + "name": "층따기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 78592, + "tables": [ + { + "pum_table_id": "F0287", + "section": "9-18. 층따기", + "source_line": 5395, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.3" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "굴착기 (무한궤도, 0.7㎥)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "적용장비", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "굴착기 (무한궤도, 0.7㎥)", + "K", + "0.9", + "" + ], + [ + "f", + "1/1.3", + "", + "" + ], + [ + "E", + "0.7", + "", + "" + ], + [ + "㎝(sec)", + "22(180°)", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-19", + "number": "9-19", + "name": "면고르기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 78848, + "tables": [] + }, + { + "work_item_code": "FP-09-19-01", + "number": "9-19-1", + "name": "토사면 고르기", + "level": 3, + "parent_code": "FP-09-19", + "sort_order": 79104, + "tables": [ + { + "pum_table_id": "F0288", + "section": "9-19-1. 토사면 고르기", + "source_line": 5414, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부 (인)", + "모래ㆍ사질토ㆍ점토ㆍ점질토", + "연질토ㆍ불순자갈", + "호박돌 섞인 고결토ㆍ경질토", + "풍화암", + "연암", + "보통암ㆍ경암" + ], + "condition_note": [ + "토 질 별", + "구 분" + ], + "raw_row": [ + [ + "보통인부 (인)", + "공기압축기 (시간)", + "소형브레이커 (시간)", + "굴착기 (시간)", + "" + ], + [ + "모래ㆍ사질토ㆍ점토ㆍ점질토", + "0.05", + "·", + "·", + "0.15" + ], + [ + "연질토ㆍ불순자갈", + "0.09", + "·", + "·", + "0.21" + ], + [ + "호박돌 섞인 고결토ㆍ경질토", + "0.1", + "·", + "·", + "0.24" + ], + [ + "풍화암", + "0.19", + "·", + "·", + "0.45" + ], + [ + "연암", + "0.46", + "1.25", + "2.45", + "·" + ], + [ + "보통암ㆍ경암", + "0.61", + "1.55", + "3.05", + "·" + ] + ] + }, + { + "pum_table_id": "F0289", + "section": "9-19-1. 토사면 고르기", + "source_line": 5430, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인력시공", + "모래 또는 사질토", + "기계시공" + ], + "condition_note": [ + "시 공", + "토 질", + "구 분", + "규 격", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "인력시공", + "점토 또는 점질토", + "보통인부", + "", + "인", + "0.19" + ], + [ + "모래 또는 사질토", + "보통인부", + "", + "인", + "0.17", + "" + ], + [ + "기계시공", + "점토, 점질토, 모래, 사질토", + "굴착기", + "0.6㎥", + "시간", + "0.09" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-19-02", + "number": "9-19-2", + "name": "비탈면 면고르기(암절취)", + "level": 3, + "parent_code": "FP-09-19", + "sort_order": 79360, + "tables": [ + { + "pum_table_id": "F0290", + "section": "9-19-2. 비탈면 고르기(암절취)", + "source_line": 5442, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인력", + "장비" + ], + "condition_note": [ + "구 분", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "인력", + "보통인부", + "인", + "0.019", + "" + ], + [ + "장비", + "유압식백호우 (무한궤도,0.7㎥)", + "hr", + "0.045", + "0.45hr/10㎥" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-19-03", + "number": "9-19-3", + "name": "비탈면 면고르기(발파암)", + "level": 3, + "parent_code": "FP-09-19", + "sort_order": 79616, + "tables": [ + { + "pum_table_id": "F0291", + "section": "9-19-3. 비탈면 고르기(발파암)", + "source_line": 5457, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "(0.0046+0.061)/2" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인력", + "장비", + "소형브레이커", + "어어호스(3/4인치)" + ], + "condition_note": [ + "구 분", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "인력", + "보통인부", + "인", + "0.0328", + "(0.0046+0.061)/2" + ], + [ + "장비", + "공기압축기(3.5㎥/min)", + "hr", + "0.140", + "(1.25+1.55)/2/10㎡" + ], + [ + "소형브레이커", + "hr", + "0.275", + "(2.45+3.05)/2/10㎡", + "" + ], + [ + "어어호스(3/4인치)", + "hr", + "0.140", + "공기압축기 Q", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-20", + "number": "9-20", + "name": "뿌리다듬기 및 적재", + "level": 2, + "parent_code": "FP-09", + "sort_order": 79872, + "tables": [] + }, + { + "work_item_code": "FP-09-20-01", + "number": "9-20-1", + "name": "뿌리다듬기", + "level": 3, + "parent_code": "FP-09-20", + "sort_order": 80128, + "tables": [ + { + "pum_table_id": "F0292", + "section": "9-20-1. 뿌리다듬기", + "source_line": 5489, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "주", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "보통인부", + "굴착기(무한궤도)", + "제잡비율" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "특별인부", + "-", + "인", + "0.63" + ], + [ + "보통인부", + "-", + "인", + "0.42" + ], + [ + "굴착기(무한궤도)", + "0.7㎥", + "hr", + "3.3" + ], + [ + "제잡비율", + "-", + "%", + "9" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-20-02", + "number": "9-20-2", + "name": "적재", + "level": 3, + "parent_code": "FP-09-20", + "sort_order": 80384, + "tables": [ + { + "pum_table_id": "F0293", + "section": "9-20-2. 적재", + "source_line": 5505, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "주", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "굴착기(무한궤도)" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수량" + ], + "raw_row": [ + [ + "특별인부", + "-", + "인", + "0.27" + ], + [ + "굴착기(무한궤도)", + "0.7㎥", + "hr", + "3.6" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-21", + "number": "9-21", + "name": "제근", + "level": 2, + "parent_code": "FP-09", + "sort_order": 80640, + "tables": [ + { + "pum_table_id": "F0294", + "section": "9-21. 제근", + "source_line": 5512, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "굴착기 (무한궤도)", + "보통인부", + "굴착기(무한궤도,0.7㎥)" + ], + "condition_note": [ + "종 류", + "명 칭", + "단위", + "소", + "중", + "밀" + ], + "raw_row": [ + [ + "굴착기 (무한궤도)", + "굴착기(무한궤도,0.2㎥)", + "hr", + "0.80", + "1.01", + "1.22" + ], + [ + "보통인부", + "인", + "0.03", + "0.04", + "0.05", + "" + ], + [ + "굴착기(무한궤도,0.7㎥)", + "hr", + "0.46", + "0.58", + "0.70", + "" + ], + [ + "보통인부", + "인", + "0.03", + "0.04", + "0.05", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-09-22", + "number": "9-22", + "name": "섞기", + "level": 2, + "parent_code": "FP-09", + "sort_order": 80896, + "tables": [ + { + "pum_table_id": "F0295", + "section": "9-22. 섞기", + "source_line": 5531, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.25" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "K(버킷계수)", + "f(토량환산계수)", + "E(작업효율)", + "Cm(1회 사이클시간)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K(버킷계수)", + "0.9", + "" + ], + [ + "f(토량환산계수)", + "1/1.25", + "" + ], + [ + "E(작업효율)", + "0.75", + "" + ], + [ + "Cm(1회 사이클시간)", + "18(90°)sec", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10", + "number": "10", + "name": "자재․장비 운반", + "level": 1, + "parent_code": null, + "sort_order": 81152, + "tables": [] + }, + { + "work_item_code": "FP-10-01", + "number": "10-1", + "name": "시멘트운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 81408, + "tables": [ + { + "pum_table_id": "F0296", + "section": "10-1. 시멘트운반", + "source_line": 5551, + "pum_form": "reference", + "form_basis": "'구역화물' — 값이 아니라 참조 지시", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "운반비", + "하차비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "운반비", + "최기역 레일도 → 현장 10.5ton 구역화물 적용", + "" + ], + [ + "하차비", + "1회", + "인도조건에 따라 변경 적용" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-02", + "number": "10-2", + "name": "철근운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 81664, + "tables": [ + { + "pum_table_id": "F0297", + "section": "10-2. 철근운반", + "source_line": 5562, + "pum_form": "reference", + "form_basis": "'구역화물' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "ton", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "운반비", + "하차비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "운반비", + "공장(하치장) → 현장 10.5ton 구역화물 적용", + "" + ], + [ + "하차비", + "1회", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-03", + "number": "10-3", + "name": "골재운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 81920, + "tables": [] + }, + { + "work_item_code": "FP-10-03-01", + "number": "10-3-1", + "name": "모래", + "level": 3, + "parent_code": "FP-10-03", + "sort_order": 82176, + "tables": [ + { + "pum_table_id": "F0298", + "section": "10-3-1. 모래", + "source_line": 5573, + "pum_form": "reference", + "form_basis": "'별도계상' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "구 입", + "운 반" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "구 입", + "별도계상", + "" + ], + [ + "운 반", + "골재원 → 현장(덤프 15ton)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-03-02", + "number": "10-3-2", + "name": "잡석", + "level": 3, + "parent_code": "FP-10-03", + "sort_order": 82432, + "tables": [ + { + "pum_table_id": "F0299", + "section": "10-3-2. 잡석", + "source_line": 5589, + "pum_form": "reference", + "form_basis": "'별도계상' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "골재생산", + "운반" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "골재생산", + "별도계상", + "" + ], + [ + "운반", + "골재원 → 현장(덤프 15ton)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-03-03", + "number": "10-3-3", + "name": "혼합골재", + "level": 3, + "parent_code": "FP-10-03", + "sort_order": 82688, + "tables": [ + { + "pum_table_id": "F0300", + "section": "10-3-3. 혼합골재", + "source_line": 5600, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "운반비", + "하차비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "운반비", + "골재원 → 현장(덤프 15ton)", + "" + ], + [ + "하차비", + "1회", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-04", + "number": "10-4", + "name": "중기운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 82944, + "tables": [ + { + "pum_table_id": "F0301", + "section": "10-4. 중기운반", + "source_line": 5611, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "운반", + "t2=운반시간 참조", + "t3=20min", + "t4=0.42min", + "㎝=t1+t2+t3+t4", + "N=60×0.9/㎝", + "To=㎝-(t1+t3)", + "트럭운반 (10.5TON)", + "t3=10min", + "t4=5min", + "자주식 운반", + "콘크리트 믹서(6.0㎥)", + "크레인(트럭 10TON)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "운반", + "트레일러운반 (20TON)", + "t1=20min", + "" + ], + [ + "t2=운반시간 참조", + "", + "", + "" + ], + [ + "t3=20min", + "", + "", + "" + ], + [ + "t4=0.42min", + "", + "", + "" + ], + [ + "㎝=t1+t2+t3+t4", + "", + "", + "" + ], + [ + "N=60×0.9/㎝", + "", + "", + "" + ], + [ + "To=㎝-(t1+t3)", + "", + "", + "" + ], + [ + "트럭운반 (10.5TON)", + "t1=10min", + "", + "" + ], + [ + "t2=운반시간 참조", + "", + "", + "" + ], + [ + "t3=10min", + "", + "", + "" + ], + [ + "t4=5min", + "", + "", + "" + ], + [ + "㎝=t1+t2+t3+t4", + "", + "", + "" + ], + [ + "N=60×0.9/㎝", + "", + "", + "" + ], + [ + "자주식 운반", + "물탱크(5500L) N=60×0.9/㎝", + "", + "" + ], + [ + "콘크리트 믹서(6.0㎥)", + "", + "", + "" + ], + [ + "크레인(트럭 10TON)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-05", + "number": "10-5", + "name": "레미콘 운반비 산정", + "level": 2, + "parent_code": "FP-10", + "sort_order": 83200, + "tables": [] + }, + { + "work_item_code": "FP-10-06", + "number": "10-6", + "name": "인력운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 83456, + "tables": [] + }, + { + "work_item_code": "FP-10-06-01", + "number": "10-6-1", + "name": "토사", + "level": 3, + "parent_code": "FP-10-06", + "sort_order": 83712, + "tables": [ + { + "pum_table_id": "F0302", + "section": "10-6-1. 토사", + "source_line": 5642, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "토사", + "20", + "30", + "40", + "50" + ], + "condition_note": [ + "거리 (m)", + "보통인부(인)", + "거리 (m)", + "보통인부(인)", + "비고" + ], + "raw_row": [ + [ + "토사", + "석재", + "토사", + "석재", + "", + "", + "" + ], + [ + "20", + "0.20", + "0.24", + "60", + "0.35", + "0.39", + "" + ], + [ + "30", + "0.24", + "0.28", + "70", + "0.39", + "0.43", + "" + ], + [ + "40", + "0.27", + "0.31", + "80", + "0.43", + "0.46", + "" + ], + [ + "50", + "0.31", + "0.35", + "90", + "0.47", + "0.51", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-06-02", + "number": "10-6-2", + "name": "돌(지게)", + "level": 3, + "parent_code": "FP-10-06", + "sort_order": 83968, + "tables": [ + { + "pum_table_id": "F0303", + "section": "10-6-2. 돌(지게)", + "source_line": 5659, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부(인)" + ], + "condition_note": [ + "거리(m)", + "50", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", + "1,000", + "비고" + ], + "raw_row": [ + [ + "보통인부(인)", + "0.5", + "0.6", + "0.8", + "1.0", + "1.2", + "1.4", + "1.6", + "1.8", + "2.0", + "2.2", + "2.4", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-06-03", + "number": "10-6-3", + "name": "기타 임업자재", + "level": 3, + "parent_code": "FP-10-06", + "sort_order": 84224, + "tables": [ + { + "pum_table_id": "F0304", + "section": "10-6-3. 기타 임업자재", + "source_line": 5669, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "소 재 (조재목)", + "m", + "20 40 60 80 100 120 140 160 180 200" + ], + "condition_note": [ + "종 별 거 리", + "목 재", + "볏 짚", + "섶단 · 새", + "나뭇 가지 단", + "자른 떼 (20㎝ ×20㎝)", + "식생낭 (혼토입)", + "편책용 말뚝 (목재, 파이프)", + "볏짚멍석 (종비포함)", + "비 료", + "비 고" + ], + "raw_row": [ + [ + "소 재 (조재목)", + "제재", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "m", + "인/㎥", + "인/㎥", + "인/ 100속", + "인/ 100속", + "인/ 100속", + "인/ 100매", + "인/ 100개", + "인/ 100본", + "인/ 1000㎡", + "인/ 톤", + "" + ], + [ + "20 40 60 80 100 120 140 160 180 200", + "0.05 0.09 0.12 0.15 0.19 0.22 0.26 0.29 0.33 0.36", + "0.04 0.07 0.1 0.13 0.17 0.2 0.23 0.26 0.29 0.32", + "0.31 0.38 0.44 0.50 0.56 0.63 0.69 0.75 0.82 0.88", + "0.21 0.25 0.29 0.33 0.38 0.42 0.46 0.50 0.54 0.59", + "0.31 0.38 0.44 0.50 0.56 0.63 0.69 0.75 0.82 0.88", + "0.07 0.09 0.11 0.13 0.14 0.16 0.18 0.20 0.21 0.23", + "0.15 0.18 0.22 0.25 0.29 0.32 0.36 0.39 0.43 0.46", + "0.06 0.10 0.13 0.17 0.21 0.25 0.29 0.33 0.37 0.41", + "0.31 0.38 0.44 0.50 0.56 0.63 0.69 0.75 0.82 0.88", + "0.11 0.14 0.17 0.21 0.24 0.27 0.31 0.34 0.37 0.41", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-07", + "number": "10-7", + "name": "모노레일 운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 84480, + "tables": [] + }, + { + "work_item_code": "FP-10-07-01", + "number": "10-7-1", + "name": "노선선정", + "level": 3, + "parent_code": "FP-10-07", + "sort_order": 84736, + "tables": [ + { + "pum_table_id": "F0305", + "section": "10-7-1. 노선선정", + "source_line": 5702, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 100.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업반장(인)", + "특별인부(인)" + ], + "condition_note": [ + "경사도 구분", + "30도 미만", + "30도 이상", + "비 고" + ], + "raw_row": [ + [ + "작업반장(인)", + "0.35", + "0.45", + "" + ], + [ + "특별인부(인)", + "0.35", + "0.45", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-07-02", + "number": "10-7-2", + "name": "가설", + "level": 3, + "parent_code": "FP-10-07", + "sort_order": 84992, + "tables": [ + { + "pum_table_id": "F0306", + "section": "10-7-2. 가설", + "source_line": 5713, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 100.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업반장(인)", + "특별인부(인)", + "보통인부(인)" + ], + "condition_note": [ + "경사도 구분", + "30도 미만", + "30도 이상", + "비 고" + ], + "raw_row": [ + [ + "작업반장(인)", + "2.0", + "2.4", + "" + ], + [ + "특별인부(인)", + "2.0", + "2.4", + "" + ], + [ + "보통인부(인)", + "6.0", + "7.2", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-07-03", + "number": "10-7-3", + "name": "철거", + "level": 3, + "parent_code": "FP-10-07", + "sort_order": 85248, + "tables": [ + { + "pum_table_id": "F0307", + "section": "10-7-3. 철거", + "source_line": 5723, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 100.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업반장(인)", + "특별인부(인)", + "보통인부(인)" + ], + "condition_note": [ + "경사도 구분", + "30도 미만", + "30도 이상", + "비 고" + ], + "raw_row": [ + [ + "작업반장(인)", + "1.0", + "1.2", + "" + ], + [ + "특별인부(인)", + "1.0", + "1.2", + "" + ], + [ + "보통인부(인)", + "3.0", + "3.6", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-07-04", + "number": "10-7-4", + "name": "모노레일 운반", + "level": 3, + "parent_code": "FP-10-07", + "sort_order": 85504, + "tables": [ + { + "pum_table_id": "F0308", + "section": "10-7-4. 모노레일 운반", + "source_line": 5741, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "차량구분", + "단궤도" + ], + "condition_note": [ + "구 분", + "콘크리트", + "토사ㆍ석재", + "블록ㆍ제 자재 등" + ], + "raw_row": [ + [ + "차량구분", + "바스켓 차량", + "보통차량", + "" + ], + [ + "단궤도", + "0.3㎥", + "0.3㎥", + "600kg, 0.3㎥" + ] + ] + }, + { + "pum_table_id": "F0309", + "section": "10-7-4. 모노레일 운반", + "source_line": 5755, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "시간(분)" + ], + "condition_note": [ + "구 분", + "콘크리트", + "토사ㆍ석재 등", + "블록ㆍ제 자재 등" + ], + "raw_row": [ + [ + "시간(분)", + "4.0", + "4.0", + "6.0" + ] + ] + }, + { + "pum_table_id": "F0310", + "section": "10-7-4. 모노레일 운반", + "source_line": 5770, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부(인)" + ], + "condition_note": [ + "직 종", + "콘크리트", + "토사ㆍ석재 등", + "블록ㆍ제 자재 등" + ], + "raw_row": [ + [ + "보통인부(인)", + "2.0", + "2.0", + "2.0" + ] + ] + }, + { + "pum_table_id": "F0311", + "section": "10-7-4. 모노레일 운반", + "source_line": 5779, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "연장", + "작업반장", + "특별인부", + "보통인부", + "모노레일 본기계", + "차체를 받치고 있는 부분 (차바퀴, 용수철, 브레이크 등)", + "레일·지지대", + "기타비용" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "연장", + "단궤도", + "m", + "", + "" + ], + [ + "작업반장", + "", + "인", + "", + "" + ], + [ + "특별인부", + "", + "〃", + "", + "" + ], + [ + "보통인부", + "", + "〃", + "", + "" + ], + [ + "모노레일 본기계", + "", + "대", + "1", + "" + ], + [ + "차체를 받치고 있는 부분 (차바퀴, 용수철, 브레이크 등)", + "", + "식", + "1", + "" + ], + [ + "레일·지지대", + "", + "〃", + "1", + "" + ], + [ + "기타비용", + "", + "%", + "20", + "" + ] + ] + }, + { + "pum_table_id": "F0312", + "section": "10-7-4. 모노레일 운반", + "source_line": 5795, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "연료비", + "보통인부" + ], + "condition_note": [ + "명 칭", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "연료비", + "ℓ", + "소요량 적용", + "ps × 0.253ℓ× 6h" + ], + [ + "보통인부", + "인", + "2", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-08", + "number": "10-8", + "name": "케이블 크레인 운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 85760, + "tables": [] + }, + { + "work_item_code": "FP-10-08-01", + "number": "10-8-1", + "name": "짐내리기", + "level": 3, + "parent_code": "FP-10-08", + "sort_order": 86016, + "tables": [ + { + "pum_table_id": "F0313", + "section": "10-8-1. 짐내리기", + "source_line": 5813, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "짐내리는 인부", + "콘크리트", + "제 자재", + "〃" + ], + "condition_note": [ + "구 분", + "운반기구", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "짐내리는 인부", + "신호수", + "", + "", + "" + ], + [ + "콘크리트", + "버켓", + "1.0", + "1.0", + "" + ], + [ + "제 자재", + "〃", + "3.0", + "1.0", + "골재 등으로 버켓을 사용하는 것" + ], + [ + "〃", + "망태기 (1.8×1.8)", + "3.0", + "1.0", + "토사, 자갈, 시멘트, 블록, 강재, 목재 등으로 중량이 큰 것" + ], + [ + "〃", + "〃", + "2.0", + "1.0", + "떼, 억새띠, 잡목묶음 등 중량이 적은 것" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-08-02", + "number": "10-8-2", + "name": "운반대 설치", + "level": 3, + "parent_code": "FP-10-08", + "sort_order": 86272, + "tables": [ + { + "pum_table_id": "F0314", + "section": "10-8-2. 운반대 설치", + "source_line": 5829, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "운반대" + ], + "condition_note": [ + "구 분", + "규 격", + "보통인부", + "소재", + "각재, 판재", + "기타비용" + ], + "raw_row": [ + [ + "운반대", + "3.0×3.0m=9.0㎡", + "7.0인", + "0.70㎥", + "0.20㎥", + "원목 + 각재ㆍ판재비의 20%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-08-03", + "number": "10-8-3", + "name": "케이블 크레인 운전", + "level": 3, + "parent_code": "FP-10-08", + "sort_order": 86528, + "tables": [ + { + "pum_table_id": "F0315", + "section": "10-8-3. 케이블 크레인 운전", + "source_line": 5840, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "보통인부", + "연료비", + "운반기구 손료" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "특별인부", + "", + "인", + "1", + "" + ], + [ + "보통인부", + "", + "〃", + "", + "짐내리기품 : 보통인부 적용" + ], + [ + "연료비", + "경유", + "L", + "", + "" + ], + [ + "운반기구 손료", + "", + "식", + "1", + "별도 계상" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-09", + "number": "10-9", + "name": "덤프트럭 운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 86784, + "tables": [] + }, + { + "work_item_code": "FP-10-09-01", + "number": "10-9-1", + "name": "자재의 1회당 표준 운반량", + "level": 3, + "parent_code": "FP-10-09", + "sort_order": 87040, + "tables": [ + { + "pum_table_id": "F0316", + "section": "10-9-1. 자재의 1회당 표준 운반량", + "source_line": 5861, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트 블 록", + "목제형틀", + "철제형틀", + "토사", + "자갈", + "강재", + "통나무" + ], + "condition_note": [ + "규격 구분", + "1t미만", + "1t이상 2t미만", + "2t이상 3t미만", + "3t이상 4t미만", + "4t이상 5t미만", + "비고" + ], + "raw_row": [ + [ + "콘크리트 블 록", + "350kg(10.9개)", + "530kg(16.8개)", + "720kg(22.8개)", + "910kg(28.7개)", + "1100kg(34.7개)", + "" + ], + [ + "목제형틀", + "170kg(14㎡)", + "220kg(18㎡)", + "270kg(22㎡)", + "320kg(26㎡)", + "370kg(30㎡)", + "" + ], + [ + "철제형틀", + "300kg(9㎡)", + "400kg(12㎡)", + "500kg(15㎡)", + "600kg(18㎡)", + "700kg(21㎡)", + "" + ], + [ + "토사", + "400kg(0.2㎥)", + "900kg(0.5㎥)", + "1050kg(0.6㎥)", + "1200kg(0.7㎥)", + "1350kg(0.8㎥)", + "" + ], + [ + "자갈", + "300kg(0.2㎥)", + "600kg(0.4㎥)", + "900kg(0.6㎥)", + "1200kg(0.8㎥)", + "1500kg(0.9㎥)", + "" + ], + [ + "강재", + "300kg", + "600kg", + "900kg", + "1200kg", + "1500kg", + "" + ], + [ + "통나무", + "160kg(0.2㎥) (10본)", + "240kg(0.3㎥) (15본)", + "330kg(0.4㎥) (20본)", + "410kg(0.5㎥) (25본)", + "490kg(0.6㎥) (30본)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-10", + "number": "10-10", + "name": "헬리콥터 자재 운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 87296, + "tables": [] + }, + { + "work_item_code": "FP-10-10-01", + "number": "10-10-1", + "name": "콘크리트 및 골재운반(지상)", + "level": 3, + "parent_code": "FP-10-10", + "sort_order": 87552, + "tables": [ + { + "pum_table_id": "F0317", + "section": "10-10-1. 콘크리트 및 골재운반(지상)", + "source_line": 5884, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업반장", + "보통인부", + "버켓 손료" + ], + "condition_note": [ + "명 칭", + "형태ㆍ치수", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "작업반장", + "", + "인", + "2.0", + "∙ 헬리콥터의 유도 등, 짐싣는 곳, 짐내리는 곳 각 1인" + ], + [ + "보통인부", + "", + "인 〃 〃 〃", + "13.0(합계) 7.0 2.0 4.0", + "∙ 버켓 채우기(7인), ∙ 계량·짐표찰 붙이기(2인) 단, 콘크리트의 경우 2인을 감한다. ∙ 적하, 소운반(4인)" + ], + [ + "버켓 손료", + "", + "개", + "4.0", + "∙ 버켓손료 무대" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-10-02", + "number": "10-10-2", + "name": "그 외 자재의 운반품셈", + "level": 3, + "parent_code": "FP-10-10", + "sort_order": 87808, + "tables": [ + { + "pum_table_id": "F0318", + "section": "10-10-2. 그 외 자재의 운반품셈", + "source_line": 5894, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업반장", + "보통인부", + "와이어 손료" + ], + "condition_note": [ + "명 칭", + "형태ㆍ치수", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "작업반장", + "", + "인", + "2.0", + "∙ 헬리콥터의 유도 등, 짐싣는 곳, 짐내리는 곳 각 1인" + ], + [ + "보통인부", + "", + "인 〃 〃 〃", + "13.0(합계) 7.0 2.0 4.0", + "∙ 결속, 계량, 짐표찰 붙이기 ∙ 후크결속 ∙ 적재 소운반 등" + ], + [ + "와이어 손료", + "", + "매", + "7.0", + "∙ 실제 운전 1시간당 손료는 4%로 한다" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-11", + "number": "10-11", + "name": "불도저 운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 88064, + "tables": [ + { + "pum_table_id": "F0319", + "section": "10-11. 불도저 운반", + "source_line": 5902, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "1/1.30", + "1/1.35", + "1/1.625" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "E", + "f" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "L", + "E", + "암석", + "q0", + "e", + "f", + "파쇄암", + "발파암", + "V1", + "V2", + "t" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "L", + "20m", + "", + "" + ], + [ + "E", + "토사", + "0.55", + "자연상태,불량" + ], + [ + "암석", + "0.25", + "흐트러진 상태, 불량", + "" + ], + [ + "q0", + "3.2m", + "", + "" + ], + [ + "e", + "0.96", + "", + "" + ], + [ + "f", + "토사", + "1/1.30", + "" + ], + [ + "파쇄암", + "1/1.35", + "", + "" + ], + [ + "발파암", + "1/1.625", + "", + "" + ], + [ + "V1", + "55m/분(2단)", + "", + "" + ], + [ + "V2", + "70m/분(2단)", + "", + "" + ], + [ + "t", + "0.25분", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-12", + "number": "10-12", + "name": "덤프 운반", + "level": 2, + "parent_code": "FP-10", + "sort_order": 88320, + "tables": [] + }, + { + "work_item_code": "FP-10-12-01", + "number": "10-12-1", + "name": "토사", + "level": 3, + "parent_code": "FP-10-12", + "sort_order": 88576, + "tables": [ + { + "pum_table_id": "F0320", + "section": "10-12-1. 토사", + "source_line": 5935, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "K", + "E0", + "파쇄암", + "㎝(초)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "K", + "0.9(0.55)", + "동 일", + "" + ], + [ + "E0", + "토사", + "0.60(불량)", + "임 도" + ], + [ + "파쇄암", + "0.35(불량)", + "", + "" + ], + [ + "㎝(초)", + "22“(180°)", + "임 도", + "" + ] + ] + }, + { + "pum_table_id": "F0321", + "section": "10-12-1. 토사", + "source_line": 5958, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "적재(t1)", + "t2", + "V2(공차)", + "적하(t3)", + "대기(t4)", + "덮개(t5)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "적재(t1)", + "굴착기", + "적재방법에 따라 산출", + "" + ], + [ + "t2", + "V1(적재)", + "5 km/hr", + "왕복시간" + ], + [ + "V2(공차)", + "6 km/hr", + "", + "" + ], + [ + "적하(t3)", + "1.1(불량)", + "적하 및 대기", + "" + ], + [ + "대기(t4)", + "0.9(진입불편)", + "", + "" + ], + [ + "덮개(t5)", + "0.5", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-12-02", + "number": "10-12-2", + "name": "암절취", + "level": 3, + "parent_code": "FP-10-12", + "sort_order": 88832, + "tables": [] + }, + { + "work_item_code": "FP-10-12-03", + "number": "10-12-3", + "name": "발파암", + "level": 3, + "parent_code": "FP-10-12", + "sort_order": 89088, + "tables": [] + }, + { + "work_item_code": "FP-10-13", + "number": "10-13", + "name": "드론 운반공", + "level": 2, + "parent_code": "FP-10", + "sort_order": 89344, + "tables": [] + }, + { + "work_item_code": "FP-10-13-01", + "number": "10-13-1", + "name": "드론운반", + "level": 3, + "parent_code": "FP-10-13", + "sort_order": 89600, + "tables": [ + { + "pum_table_id": "F0322", + "section": "10-13-1. 드론운반", + "source_line": 6003, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인원(명)" + ], + "condition_note": [ + "수평거리", + "100m", + "200m", + "300m", + "400m", + "500m", + "600m" + ], + "raw_row": [ + [ + "인원(명)", + "0.29", + "0.37", + "0.46", + "0.54", + "0.62", + "0.70" + ] + ] + } + ] + }, + { + "work_item_code": "FP-10-13-02", + "number": "10-13-2", + "name": "인력운반", + "level": 3, + "parent_code": "FP-10-13", + "sort_order": 89856, + "tables": [ + { + "pum_table_id": "F0323", + "section": "10-13-2. 인력운반", + "source_line": 6009, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인원(명)" + ], + "condition_note": [ + "수평거리", + "100m", + "200m", + "300m", + "400m", + "500m", + "600m" + ], + "raw_row": [ + [ + "인원(명)", + "0.67", + "0.73", + "0.79", + "0.85", + "0.92", + "0.99" + ] + ] + }, + { + "pum_table_id": "F0324", + "section": "10-13-2. 인력운반", + "source_line": 6013, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "① 드론준비시간(분)", + "② 왕복비행시간(분)", + "③ 1회 비행 운반 본수", + "④ 드론운반 작업인원" + ], + "condition_note": [ + "항 목", + "내 용", + "적용치" + ], + "raw_row": [ + [ + "① 드론준비시간(분)", + "ㆍ이륙지점에 도착한 후 묘목운반 개시까지 준비시간 (점검, 보정, 테스트 비행을 포함)", + "60" + ], + [ + "② 왕복비행시간(분)", + "ㆍ운반거리에 대한 드론의 왕복 시간 (적재, 하적, 배터리 교환을 포함)", + "0.0098×수평거리+1.3264" + ], + [ + "③ 1회 비행 운반 본수", + "ㆍ페이로드 15kg 드론의 150cc 용기묘 운반을 가정한 1회 비행 운반본수", + "100" + ], + [ + "④ 드론운반 작업인원", + "ㆍ2인 조종을 가정한 작업인수 (조종수 2인, 배터리 교환, 적재작업, 안전관리담당자 등)", + "3" + ] + ] + } + ] + }, + { + "work_item_code": "FP-11", + "number": "11", + "name": "가설", + "level": 1, + "parent_code": null, + "sort_order": 90112, + "tables": [] + }, + { + "work_item_code": "FP-11-01", + "number": "11-1", + "name": "콘테이너형 가설건축물", + "level": 2, + "parent_code": "FP-11", + "sort_order": 90368, + "tables": [ + { + "pum_table_id": "F0325", + "section": "11-1. 콘테이너형 가설건축물", + "source_line": 6026, + "pum_form": "reference", + "form_basis": "'준용' — 값이 아니라 참조 지시", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "비계공", + "2.4M 3.0M 3.5M 4.8M 6.0M" + ], + "condition_note": [ + "길이 폭", + "3M", + "6M", + "9M", + "12M", + "비고" + ], + "raw_row": [ + [ + "비계공", + "특별인부", + "비계공", + "특별인부", + "비계공", + "특별인부", + "비계공", + "특별인부", + "", + "" + ], + [ + "2.4M 3.0M 3.5M 4.8M 6.0M", + "0.29 0.33 0.36 0.44 0.5", + "0.14 0.17 0.18 0.22 0.25", + "0.44 0.5 0.53 0.61 0.66", + "0.22 0.25 0.26 0.3 0.33", + "0.53 0.59 0.61 0.71 0.77", + "0.16 0.29 0.3 0.36 0.38", + "0.61 0.67 0.71 0.77 0.8", + "0.3 0.33 0.36 0.38 0.4", + "H=2.6M 기준 용도: 사무실, 창고" + ] + ] + } + ] + }, + { + "work_item_code": "FP-11-02", + "number": "11-2", + "name": "토공의 비탈 규준틀", + "level": 2, + "parent_code": "FP-11", + "sort_order": 90624, + "tables": [ + { + "pum_table_id": "F0326", + "section": "11-2. 토공의 비탈 규준틀", + "source_line": 6043, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "건축목공", + "보통인부" + ], + "condition_note": [ + "종 류", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "건축목공", + "인", + "0.16" + ], + [ + "보통인부", + "인", + "0.14" + ] + ] + } + ] + }, + { + "work_item_code": "FP-11-03", + "number": "11-3", + "name": "수평규준틀", + "level": 2, + "parent_code": "FP-11", + "sort_order": 90880, + "tables": [ + { + "pum_table_id": "F0327", + "section": "11-3. 수평규준틀", + "source_line": 6056, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "건축목공", + "보통인부" + ], + "condition_note": [ + "종 류", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "건축목공", + "인", + "0.21" + ], + [ + "보통인부", + "인", + "0.19" + ] + ] + } + ] + }, + { + "work_item_code": "FP-11-04", + "number": "11-4", + "name": "쇄석․혼합석 부설", + "level": 2, + "parent_code": "FP-11", + "sort_order": 91136, + "tables": [ + { + "pum_table_id": "F0328", + "section": "11-4. 쇄석·혼합석 부설", + "source_line": 6071, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "q", + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "부설장비", + "k", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "부설장비", + "유압식백호우 (무한궤도 0.7㎥)", + "q", + "0.7", + "" + ], + [ + "k", + "0.55", + "", + "", + "" + ], + [ + "f", + "1", + "", + "", + "" + ], + [ + "E", + "0.35", + "", + "", + "" + ], + [ + "㎝(sec)", + "20(135°)", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12", + "number": "12", + "name": "철근콘크리트", + "level": 1, + "parent_code": null, + "sort_order": 91392, + "tables": [] + }, + { + "work_item_code": "FP-12-01", + "number": "12-1", + "name": "콘크리트 타설", + "level": 2, + "parent_code": "FP-12", + "sort_order": 91648, + "tables": [] + }, + { + "work_item_code": "FP-12-01-01", + "number": "12-1-1", + "name": "레디믹스트콘크리트 타설", + "level": 3, + "parent_code": "FP-12-01", + "sort_order": 91904, + "tables": [ + { + "pum_table_id": "F0329", + "section": "12-1-1. 레디믹스트콘크리트 타설", + "source_line": 6090, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "무근구조물", + "철근구조물", + "소형구조물" + ], + "condition_note": [ + "구 분", + "콘크리트공(인)", + "보통인부(인)" + ], + "raw_row": [ + [ + "무근구조물", + "0.12", + "0.15" + ], + [ + "철근구조물", + "0.14", + "0.16" + ], + [ + "소형구조물", + "0.24", + "0.30" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-01-02", + "number": "12-1-2", + "name": "기계비빔 타설", + "level": 3, + "parent_code": "FP-12-01", + "sort_order": 92160, + "tables": [ + { + "pum_table_id": "F0330", + "section": "12-1-2. 기계비빔타설", + "source_line": 6102, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "무근구조물", + "철근구조물", + "소형구조물" + ], + "condition_note": [ + "구 분", + "콘크리트공(인)", + "보통인부(인)" + ], + "raw_row": [ + [ + "무근구조물", + "0.15", + "0.46" + ], + [ + "철근구조물", + "0.17", + "0.68" + ], + [ + "소형구조물", + "0.24", + "0.94" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-01-03", + "number": "12-1-3", + "name": "인력비빔 타설", + "level": 3, + "parent_code": "FP-12-01", + "sort_order": 92416, + "tables": [ + { + "pum_table_id": "F0331", + "section": "12-1-3. 인력비빔타설", + "source_line": 6115, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "무근구조물", + "철근구조물", + "소형구조물" + ], + "condition_note": [ + "구 분", + "콘크리트공(인)", + "보통인부(인)" + ], + "raw_row": [ + [ + "무근구조물", + "0.85", + "0.82" + ], + [ + "철근구조물", + "0.87", + "0.99" + ], + [ + "소형구조물", + "1.29", + "1.36" + ] + ] + }, + { + "pum_table_id": "F0332", + "section": "12-1-3. 인력비빔타설", + "source_line": 6128, + "pum_form": "requirement", + "form_basis": "값 단위 '(kg)'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "25", + "(B)", + "(C)", + "40" + ], + "condition_note": [ + "골재의 최대치수(㎜)", + "배합종류", + "시멘트(kg)", + "모래(kg)", + "자갈 또는 부순돌(kg)" + ], + "raw_row": [ + [ + "25", + "(A)", + "357", + "893", + "931" + ], + [ + "(B)", + "346", + "828", + "1,011", + "" + ], + [ + "(C)", + "340", + "779", + "1,049", + "" + ], + [ + "40", + "(A)", + "335", + "838", + "1,032" + ], + [ + "(B)", + "323", + "775", + "1,101", + "" + ], + [ + "(C)", + "318", + "728", + "1,157", + "" + ] + ] + }, + { + "pum_table_id": "F0333", + "section": "12-1-3. 인력비빔타설", + "source_line": 6145, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "25", + "(B)", + "(C)", + "40" + ], + "condition_note": [ + "온도 품종", + "00C때", + "-50C때", + "-100C때", + "-200C때" + ], + "raw_row": [ + [ + "25", + "(A)", + "357", + "893", + "931" + ], + [ + "(B)", + "346", + "828", + "1,011", + "" + ], + [ + "(C)", + "340", + "779", + "1,049", + "" + ], + [ + "40", + "(A)", + "335", + "838", + "1,032" + ], + [ + "(B)", + "323", + "775", + "1,101", + "" + ], + [ + "(C)", + "318", + "728", + "1,157", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-02", + "number": "12-2", + "name": "표면 마무리", + "level": 2, + "parent_code": "FP-12", + "sort_order": 92672, + "tables": [ + { + "pum_table_id": "F0334", + "section": "12-2. 표면 마무리", + "source_line": 6161, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "미 장 공" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "미 장 공" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "미 장 공", + "인", + "0.34" + ] + ] + }, + { + "pum_table_id": "F0356", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6520, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "무근콘크리트", + "8 ~ 12 cm", + "15 cm", + "18 cm 이상" + ], + "condition_note": [ + "슬 럼 프", + "기준 시공량" + ], + "raw_row": [ + [ + "무근콘크리트", + "철근콘크리트", + "" + ], + [ + "8 ~ 12 cm", + "130", + "125" + ], + [ + "15 cm", + "135", + "130" + ], + [ + "18 cm 이상", + "145", + "140" + ] + ] + }, + { + "pum_table_id": "F0357", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6534, + "pum_form": "coefficient", + "form_basis": "행 키가 기호뿐 ['f₁']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "f₁" + ], + "condition_note": [ + "유 형", + "Type-Ⅰ", + "Type-Ⅱ", + "Type-Ⅲ", + "Type-Ⅳ" + ], + "raw_row": [ + [ + "f₁", + "1.4", + "1.0", + "0.8", + "0.3" + ] + ] + }, + { + "pum_table_id": "F0358", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6538, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "Type-Ⅰ", + "Type-Ⅱ", + "Type-Ⅲ", + "Type-Ⅳ" + ], + "condition_note": [ + "구 분", + "적용 기준" + ], + "raw_row": [ + [ + "Type-Ⅰ", + "매트기초 등 펌프차 작업에 제약이 없는 시설물" + ], + [ + "Type-Ⅱ", + "벽, 기둥, 보, 슬래브. 교대, 교각 등 펌프차 작업에 큰 지장이 없어 일반적인 시공이 가능한 시설물" + ], + [ + "Type-Ⅲ", + "옹벽, 줄기초, 슬래브 없는[월거더 : wall girder] 구조의 기둥과 보 등 펌프차 작업에 제약을 받는 타설 부위가 좁거나 깊은 시설물" + ], + [ + "Type-Ⅳ", + "절·성토부 비탈면에 시공되는 구조물 등 펌프차 작업에 제약이 매우 큰 시설물" + ] + ] + }, + { + "pum_table_id": "F0359", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6547, + "pum_form": "coefficient", + "form_basis": "행 키가 기호뿐 ['f₁']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "f₁" + ], + "condition_note": [ + "유 형", + "Type-Ⅰ", + "Type-Ⅱ", + "Type-Ⅲ" + ], + "raw_row": [ + [ + "f₁", + "1.2", + "1.0", + "0.8" + ] + ] + }, + { + "pum_table_id": "F0360", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6551, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "Type-Ⅰ", + "Type-Ⅱ", + "Type-Ⅲ" + ], + "condition_note": [ + "구 분", + "적용 기준" + ], + "raw_row": [ + [ + "Type-Ⅰ", + "대기 공간이 충분히 넓어 믹서트럭 2대가 병렬로 타설 준비가 가능하며 지속적인 타설을 수행하는 경우" + ], + [ + "Type-Ⅱ", + "믹서트럭이 1대씩 직렬로 대기하며 순차적으로 타설 준비하여 타설하는 일반적인 경우" + ], + [ + "Type-Ⅲ", + "믹서트럭의 대기 공간이 매우 협소하고 진출입 길이가 길어 연속적인 타설이 어려운 경우" + ] + ] + }, + { + "pum_table_id": "F0361", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6560, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "시멘트(kg)", + "1:2:4", + "1:3:6", + "1:4:8" + ], + "condition_note": [ + "배 합 비", + "재 료", + "손 비 비 기" + ], + "raw_row": [ + [ + "시멘트(kg)", + "모래(㎥)", + "자갈(㎥)", + "콘크리트공(인)", + "보통인부(인)", + "" + ], + [ + "1:2:4", + "320", + "0.45", + "0.90", + "0.9", + "1.0" + ], + [ + "1:3:6", + "220", + "0.47", + "0.94", + "0.9", + "0.9" + ], + [ + "1:4:8", + "170", + "0.48", + "0.96", + "0.9", + "0.7" + ] + ] + }, + { + "pum_table_id": "F0362", + "section": "12-2 표면 마무리를 따른다.", + "source_line": 6569, + "pum_form": "requirement", + "form_basis": "값 단위 '(m)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "설 치", + "비 계 공" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량", + "시 공 량(m)" + ], + "raw_row": [ + [ + "설 치", + "철 거", + "", + "", + "" + ], + [ + "비 계 공", + "인/일", + "2", + "220", + "330" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-03", + "number": "12-3", + "name": "철근 현장가공 및 조림", + "level": 2, + "parent_code": "FP-12", + "sort_order": 92928, + "tables": [ + { + "pum_table_id": "F0335", + "section": "12-3. 철근 현장가공 및 조립", + "source_line": 6171, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "ton", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "철근공(인)", + "간 단", + "보 통", + "복 잡", + "매우복잡" + ], + "condition_note": [ + "구조별", + "가 공", + "조 립", + "계" + ], + "raw_row": [ + [ + "철근공(인)", + "보통인부(인)", + "철근공(인)", + "보통인부(인)", + "철근공(인)", + "보통인부(인)", + "" + ], + [ + "간 단", + "1.07", + "0.35", + "1.69", + "0.69", + "2.76", + "1.04" + ], + [ + "보 통", + "1.24", + "0.45", + "1.84", + "0.75", + "3.08", + "1.20" + ], + [ + "복 잡", + "1.51", + "0.50", + "1.92", + "0.80", + "3.43", + "1.30" + ], + [ + "매우복잡", + "1.69", + "0.60", + "2.14", + "0.86", + "3.83", + "1.46" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-04", + "number": "12-4", + "name": "합판거푸집", + "level": 2, + "parent_code": "FP-12", + "sort_order": 93184, + "tables": [ + { + "pum_table_id": "F0336", + "section": "12-4. 합판거푸집", + "source_line": 6191, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "합 판", + "각 재", + "철 선", + "박 리 제" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "횟수별", + "합 판", + "각 재", + "철 선", + "못", + "박 리 제", + "형틀목공", + "보통인부", + "사용고재 평가기준", + "비 고" + ], + "condition_note": [ + "구 분", + "단위", + "기준수량 (1회사용)", + "사용횟수별기준수량에대한 비율(%)", + "비 고" + ], + "raw_row": [ + [ + "횟수별", + "재료별(%)", + "노무비(%)", + "", + "", + "", + "" + ], + [ + "합 판", + "㎡", + "1.030", + "1회사용시 2회사용시 3회사용시 4회사용시 5회사용시 6회사용시", + "100.0 57.0 46.1 40.1 37.1 34.7", + "100.0 60.0 47.1 40.0 34.2 32.0", + "12mm내수합판기준" + ], + [ + "각 재", + "㎥", + "0.038", + "", + "", + "", + "" + ], + [ + "철 선", + "kg", + "0.29", + "", + "", + "", + "" + ], + [ + "못", + "kg", + "0.20", + "", + "", + "", + "" + ], + [ + "박 리 제", + "ℓ", + "0.19", + "", + "", + "", + "" + ], + [ + "형틀목공", + "인", + "0.22", + "제작조립 철거포함", + "", + "", + "" + ], + [ + "보통인부", + "인", + "0.12", + "", + "", + "", + "" + ], + [ + "사용고재 평가기준", + "%", + "23", + "", + "", + "합판과 각재의 설계단가를 기준으로 함", + "" + ], + [ + "비 고", + "- 본 품은 수직고 7m까지 적용하며, 이를 초과하는 경우 매3m증가마다 인력품을 10%까지 할증한다. 다만 현장여건에 따라 장비가 필요하다고 판단되는 구조물에서는 장비로 계상할 수 있다.", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-05", + "number": "12-5", + "name": "문양거푸집", + "level": 2, + "parent_code": "FP-12", + "sort_order": 93440, + "tables": [ + { + "pum_table_id": "F0337", + "section": "12-5. 문양거푸집(0~7m)", + "source_line": 6217, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "문양 스티로폴(자재비 포함)", + "설치 및 해체", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "문양 스티로폴(자재비 포함)", + "㎡", + "1.0", + "", + "" + ], + [ + "설치 및 해체", + "형틀목공", + "인", + "0.07", + "" + ], + [ + "보통인부", + "인", + "0.03", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-06", + "number": "12-6", + "name": "콘크리트 포장(인력시공)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 93696, + "tables": [ + { + "pum_table_id": "F0338", + "section": "12-6. 콘크리트 포장(인력시공)", + "source_line": 6231, + "pum_form": "reference", + "form_basis": "'적용한다' — 값이 아니라 참조 지시", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트믹서트럭 직접타설인경우", + "포장공", + "30㎝", + "보통인부", + "40㎝" + ], + "condition_note": [ + "배치인원(인)", + "포장두께", + "시공량(㎥)" + ], + "raw_row": [ + [ + "콘크리트믹서트럭 직접타설인경우", + "콘크리트믹서트럭 후진 진입 또는 경운기 등으로 운반인 경우", + "", + "", + "" + ], + [ + "포장공", + "3", + "20㎝", + "100", + "좌측 시공량의 50%까지 감하여 적용한다." + ], + [ + "30㎝", + "150", + "", + "", + "" + ], + [ + "보통인부", + "3", + "", + "", + "" + ], + [ + "40㎝", + "200", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-07", + "number": "12-7", + "name": "포장절단 및 줄눈설치", + "level": 2, + "parent_code": "FP-12", + "sort_order": 93952, + "tables": [] + }, + { + "work_item_code": "FP-12-07-01", + "number": "12-7-1", + "name": "포장절단", + "level": 3, + "parent_code": "FP-12-07", + "sort_order": 94208, + "tables": [ + { + "pum_table_id": "F0339", + "section": "12-7-1. 포장절단", + "source_line": 6254, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "명칭", + "특별인부", + "보통인부" + ], + "condition_note": [ + "배치인원(인)", + "사용기계(1대)", + "시공량(m)" + ], + "raw_row": [ + [ + "명칭", + "규격", + "형식", + "시공량", + "", + "" + ], + [ + "특별인부", + "1", + "커터", + "320~400mm", + "임도(간선, 작업)", + "350" + ], + [ + "보통인부", + "2", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-07-02", + "number": "12-7-2", + "name": "줄눈설치", + "level": 3, + "parent_code": "FP-12-07", + "sort_order": 94464, + "tables": [ + { + "pum_table_id": "F0340", + "section": "12-7-2. 줄눈설치", + "source_line": 6269, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "보통인부" + ], + "condition_note": [ + "배치인원(인)", + "시공량(m)" + ], + "raw_row": [ + [ + "특별인부", + "2", + "700" + ], + [ + "보통인부", + "3", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-08", + "number": "12-8", + "name": "콘크리트 포장 거푸집", + "level": 2, + "parent_code": "FP-12", + "sort_order": 94720, + "tables": [ + { + "pum_table_id": "F0341", + "section": "12-8. 콘크리트 포장 거푸집", + "source_line": 6280, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "포장두께(㎝)", + "형틀목공 보통인부", + "20㎝ ≤ 포장두께 ≤ 25㎝", + "25㎝ ≤ 포장두께 ≤ 30㎝", + "30㎝ ≤ 포장두께 ≤ 40㎝" + ], + "condition_note": [ + "배치인원(인)", + "시공량(거푸집연장 m)" + ], + "raw_row": [ + [ + "포장두께(㎝)", + "시공량", + "", + "" + ], + [ + "형틀목공 보통인부", + "2 1", + "포장두께 ≤ 20㎝", + "100" + ], + [ + "20㎝ ≤ 포장두께 ≤ 25㎝", + "85", + "", + "" + ], + [ + "25㎝ ≤ 포장두께 ≤ 30㎝", + "70", + "", + "" + ], + [ + "30㎝ ≤ 포장두께 ≤ 40㎝", + "50", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-09", + "number": "12-9", + "name": "측구", + "level": 2, + "parent_code": "FP-12", + "sort_order": 94976, + "tables": [] + }, + { + "work_item_code": "FP-12-09-01", + "number": "12-9-1", + "name": "L형 측구", + "level": 3, + "parent_code": "FP-12-09", + "sort_order": 95232, + "tables": [ + { + "pum_table_id": "F0342", + "section": "12-9-1. L형 측구(인력시공)", + "source_line": 6299, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트(레미콘)", + "거 푸 집", + "연결철근", + "수축줄눈", + "신축이음", + "비닐깔기", + "배수파이프 설치" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "콘크리트(레미콘)", + "무근,진동기", + "㎥", + "", + "" + ], + [ + "거 푸 집", + "합판4회", + "회", + "", + "반중력식 옹벽 참조" + ], + [ + "연결철근", + "SD300, D16", + "ton", + "", + "철근가공조립(간단)의 30%" + ], + [ + "수축줄눈", + "", + "m", + "", + "" + ], + [ + "신축이음", + "", + "㎡", + "", + "" + ], + [ + "비닐깔기", + "", + "㎡", + "", + "" + ], + [ + "배수파이프 설치", + "", + "m", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-09-02", + "number": "12-9-2", + "name": "산마루 측구", + "level": 3, + "parent_code": "FP-12-09", + "sort_order": 95488, + "tables": [ + { + "pum_table_id": "F0343", + "section": "12-9-2. 산마루 측구", + "source_line": 6316, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트", + "거 푸 집", + "철근가공조립", + "수축줄눈", + "신축이음", + "비닐깔기" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "콘크리트", + "펌프차타설", + "㎥", + "", + "" + ], + [ + "거 푸 집", + "합판4회", + "회", + "", + "" + ], + [ + "철근가공조립", + "", + "ton", + "", + "철근가공조립(간단)의 30%" + ], + [ + "수축줄눈", + "", + "m", + "", + "" + ], + [ + "신축이음", + "", + "㎡", + "", + "" + ], + [ + "비닐깔기", + "", + "㎡", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-09-03", + "number": "12-9-3", + "name": "소단 측구", + "level": 3, + "parent_code": "FP-12-09", + "sort_order": 95744, + "tables": [ + { + "pum_table_id": "F0344", + "section": "12-9-3. 소단 측구", + "source_line": 6331, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트", + "거 푸 집" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "콘크리트", + "펌프차 타설", + "㎥", + "", + "" + ], + [ + "거 푸 집", + "합판4회", + "회", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-10", + "number": "12-10", + "name": "맹암거", + "level": 2, + "parent_code": "FP-12", + "sort_order": 96000, + "tables": [ + { + "pum_table_id": "F0345", + "section": "12-10. 맹암거", + "source_line": 6340, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "유공관 설치", + "배관공", + "특별인부", + "부직포", + "보통인부", + "잡석", + "소운반 인부", + "토공" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "유공관 설치", + "유공관", + "∅200mm", + "m", + "1.0", + "" + ], + [ + "배관공", + "", + "인", + "0.0055", + "0.022인×1/4", + "" + ], + [ + "특별인부", + "", + "인", + "0.0055", + "\"", + "" + ], + [ + "부직포", + "자재", + "", + "㎡", + "", + "자재비 별산" + ], + [ + "보통인부", + "", + "인", + "0.003", + "", + "" + ], + [ + "잡석", + "구입 및 운반", + "", + "㎥", + "", + "별산(깬잡석 유용가능)" + ], + [ + "소운반 인부", + "리어카(L=50m)", + "인", + "2.0", + "필요시 적용", + "" + ], + [ + "보통인부", + "부설인부", + "인", + "0.13", + "", + "" + ], + [ + "토공", + "", + "", + "㎥", + "", + "토질조건별로 반영" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-11", + "number": "12-11", + "name": "관부설", + "level": 2, + "parent_code": "FP-12", + "sort_order": 96256, + "tables": [] + }, + { + "work_item_code": "FP-12-11-01", + "number": "12-11-1", + "name": "VR(소켓식)", + "level": 3, + "parent_code": "FP-12-11", + "sort_order": 96512, + "tables": [ + { + "pum_table_id": "F0346", + "section": "12-11-1. VR관(소켓식)", + "source_line": 6360, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "0.62/2.5", + "0.76/2.5", + "0.90/2.5", + "0.26/2.5", + "0.35/2.5", + "0.46/2.5", + "0.96/2.5", + "1.78/2.5", + "2.35/2.5" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "∅800mm", + "VR관", + "고무링", + "지수활제", + "기초콘크리트", + "거 푸 집", + "크레인", + "배관공", + "보통인부" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "관 경 별 적 용", + "비 고" + ], + "raw_row": [ + [ + "∅800mm", + "∅1000mm", + "∅1200mm", + "", + "", + "", + "" + ], + [ + "VR관", + "", + "m", + "1.0", + "1.0", + "1.0", + "별도계산" + ], + [ + "고무링", + "", + "개", + "1.0", + "1.0", + "1.0", + "" + ], + [ + "지수활제", + "", + "g", + "140", + "180", + "240", + "" + ], + [ + "기초콘크리트", + "무근", + "㎥", + "", + "", + "", + "설계수량" + ], + [ + "거 푸 집", + "합판6회", + "회", + "", + "", + "", + "〃" + ], + [ + "크레인", + "10ton", + "hr", + "0.62/2.5", + "0.76/2.5", + "0.90/2.5", + "" + ], + [ + "배관공", + "", + "인", + "0.26/2.5", + "0.35/2.5", + "0.46/2.5", + "" + ], + [ + "보통인부", + "", + "인", + "0.96/2.5", + "1.78/2.5", + "2.35/2.5", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-11-02", + "number": "12-11-2", + "name": "흄관", + "level": 3, + "parent_code": "FP-12-11", + "sort_order": 96768, + "tables": [ + { + "pum_table_id": "F0347", + "section": "12-11-2. 흄관", + "source_line": 6381, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "0.62/2.5", + "0.76/2.5", + "0.90/2.5", + "0.26/2.5", + "0.35/2.5", + "0.46/2.5", + "0.96/2.5", + "1.78/2.5", + "2.35/2.5", + "0.016/2.5/2", + "0.0298/2.5/2", + "0.0355/2.5/2" + ], + "crew_table": false, + "spaced_names": [ + "흄 관" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "∅800mm", + "흄 관", + "기초콘크리트", + "거 푸 집", + "크레인", + "배관공", + "보통인부", + "접합몰탈" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "관 경 별 적 용", + "비고" + ], + "raw_row": [ + [ + "∅800mm", + "∅1000mm", + "∅1200mm", + "", + "", + "", + "" + ], + [ + "흄 관", + "", + "m", + "1.0", + "1.0", + "1.0", + "별산" + ], + [ + "기초콘크리트", + "무근", + "㎥", + "", + "", + "", + "설계 수량" + ], + [ + "거 푸 집", + "합판6회", + "회", + "", + "", + "", + "" + ], + [ + "크레인", + "10ton", + "hr", + "0.62/2.5", + "0.76/2.5", + "0.90/2.5", + "" + ], + [ + "배관공", + "", + "인", + "0.26/2.5", + "0.35/2.5", + "0.46/2.5", + "" + ], + [ + "보통인부", + "", + "인", + "0.96/2.5", + "1.78/2.5", + "2.35/2.5", + "" + ], + [ + "접합몰탈", + "1:2", + "㎥", + "0.016/2.5/2", + "0.0298/2.5/2", + "0.0355/2.5/2", + "" + ] + ] + }, + { + "pum_table_id": "F0348", + "section": "12-11-2. 흄관", + "source_line": 6395, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "절단기", + "일반기계운전사", + "보통인부", + "잡재료비" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "절단기", + "40.64㎝", + "hr", + "0.93", + "" + ], + [ + "일반기계운전사", + "", + "인", + "0.12", + "" + ], + [ + "보통인부", + "", + "인", + "1.23", + "" + ], + [ + "잡재료비", + "", + "", + "", + "별도 계상" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-11-03", + "number": "12-11-3", + "name": "파형강관", + "level": 3, + "parent_code": "FP-12-11", + "sort_order": 97024, + "tables": [ + { + "pum_table_id": "F0349", + "section": "12-11-3. 파형강관", + "source_line": 6406, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "0.31/8.0", + "0.37/8.0", + "0.43/8.0", + "0.25/8.0", + "0.22/8.0", + "0.41/8.0", + "0.15/8.0", + "0.19/8.0", + "0.23/8.0" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "∅800mm", + "파형강관", + "커플링밴드", + "크레인", + "배관공", + "보통인부", + "모래부설" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "관 경 별 적 용", + "비 고" + ], + "raw_row": [ + [ + "∅800mm", + "∅1000mm", + "∅1200mm", + "", + "", + "", + "" + ], + [ + "파형강관", + "", + "m", + "1.0", + "1.0", + "1.0", + "별산" + ], + [ + "커플링밴드", + "", + "EA", + "", + "", + "", + "필요시적용" + ], + [ + "크레인", + "5ton", + "hr", + "0.31/8.0", + "0.37/8.0", + "0.43/8.0", + "" + ], + [ + "배관공", + "", + "인", + "0.25/8.0", + "0.22/8.0", + "0.41/8.0", + "" + ], + [ + "보통인부", + "", + "인", + "0.15/8.0", + "0.19/8.0", + "0.23/8.0", + "" + ], + [ + "모래부설", + "또는 양질토사", + "㎥", + "", + "", + "", + "설계수량" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-12", + "number": "12-12", + "name": "날개벽", + "level": 2, + "parent_code": "FP-12", + "sort_order": 97280, + "tables": [ + { + "pum_table_id": "F0350", + "section": "12-12. 날개벽", + "source_line": 6421, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트 (레미콘)", + "다짐:봉상후렉시블(45mm)", + "거 푸 집", + "철근", + "기초잡석 운반, 부설 및 다짐", + "소할 (30%) 브레이커", + "적사(굴착기 0.7㎥)", + "운반(덤프트럭 15톤)", + "부 설 다 짐", + "바닥정리" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "콘크리트 (레미콘)", + "", + "㎥", + "", + "콘크리트공0.15인/㎥, 보통인부 0.27인/㎥" + ], + [ + "다짐:봉상후렉시블(45mm)", + "대", + "1", + "Q=5.4㎥/hr", + "" + ], + [ + "거 푸 집", + "합판4회", + "㎡", + "", + "" + ], + [ + "철근", + "SD300, D13", + "ton", + "", + "철근가공조립(간단)" + ], + [ + "기초잡석 운반, 부설 및 다짐", + "구입 현장발파암 유용", + "㎥", + "", + "" + ], + [ + "소할 (30%) 브레이커", + "㎥", + "", + "", + "" + ], + [ + "적사(굴착기 0.7㎥)", + "㎥", + "", + "", + "" + ], + [ + "운반(덤프트럭 15톤)", + "㎥", + "", + "", + "" + ], + [ + "부 설 다 짐", + "인", + "", + "보통인부 0.6인/㎥", + "" + ], + [ + "바닥정리", + "", + "㎡", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-13", + "number": "12-13", + "name": "면벅", + "level": 2, + "parent_code": "FP-12", + "sort_order": 97536, + "tables": [ + { + "pum_table_id": "F0351", + "section": "12-13. 면벽", + "source_line": 6440, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "콘크리트 (레미콘)", + "다짐:봉상후렉시블(45mm)", + "거 푸 집", + "기초잡석 운반, 부설 및 다짐", + "소할 (30%) 브레이커", + "적사(굴착기 0.7㎥)", + "운반(덤프트럭 15톤)", + "부 설 다 짐" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "콘크리트 (레미콘)", + "", + "㎥", + "", + "콘크리트공0.15인/㎥, 보통인부 0.27인/㎥" + ], + [ + "다짐:봉상후렉시블(45mm)", + "대", + "1", + "Q=5.4㎥/hr", + "" + ], + [ + "거 푸 집", + "합판4회", + "㎡", + "", + "" + ], + [ + "기초잡석 운반, 부설 및 다짐", + "구입 현장발파암 유용", + "㎥", + "", + "" + ], + [ + "소할 (30%) 브레이커", + "㎥", + "", + "", + "" + ], + [ + "적사(굴착기 0.7㎥)", + "㎥", + "", + "", + "" + ], + [ + "운반(덤프트럭 15톤)", + "㎥", + "", + "", + "" + ], + [ + "부 설 다 짐", + "인", + "", + "보통인부 0.6인/㎥", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-14", + "number": "12-14", + "name": "가배수관", + "level": 2, + "parent_code": "FP-12", + "sort_order": 97792, + "tables": [ + { + "pum_table_id": "F0352", + "section": "12-14. 가배수관", + "source_line": 6455, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "파형강관부설", + "철 거" + ], + "condition_note": [ + "구 분", + "관경(㎜)", + "배관공(수도)(인)", + "보통인부(인)", + "크레인(hr)", + "비고" + ], + "raw_row": [ + [ + "파형강관부설", + "250 300 400 450 500 600 700 800 1,000 1,200 1,500", + "0.04 0.06 0.10 0.12 0.13 0.17 0.21 0.24 0.32 0.39 0.50", + "0.02 0.03 0.05 0.06 0.07 0.08 0.10 0.12 0.16 0.19 0.25", + "0.12 0.13 0.16 0.17 0.18 0.20 0.23 0.25 0.30 0.35 0.43", + "" + ], + [ + "철 거", + "", + "", + "", + "", + "부설비의 50%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-15", + "number": "12-15", + "name": "집수정", + "level": 2, + "parent_code": "FP-12", + "sort_order": 98048, + "tables": [ + { + "pum_table_id": "F0353", + "section": "12-15. 집수정", + "source_line": 6464, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "구체콘크리트 (레미콘)", + "다짐:봉상후렉시블(45mm)", + "버림콘크리트 (레미콘)", + "거 푸 집", + "철근", + "집수정 뚜껑", + "설치비" + ], + "condition_note": [ + "구 분", + "규 격", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "구체콘크리트 (레미콘)", + "철근", + "㎥", + "", + "콘크리트공0.24인/㎥, 보통인부 0.42인/㎥" + ], + [ + "다짐:봉상후렉시블(45mm)", + "대", + "1", + "Q=5.4㎥/hr", + "" + ], + [ + "버림콘크리트 (레미콘)", + "무근", + "㎥", + "", + "콘크리트공0.15인/㎥,보통인부 0.27인/㎥" + ], + [ + "거 푸 집", + "합판4회", + "㎡", + "", + "" + ], + [ + "철근", + "철근가공조립(보통)", + "ton", + "", + "" + ], + [ + "집수정 뚜껑", + "스틸그레이팅", + "개", + "", + "" + ], + [ + "설치비", + "%", + "5", + "재료비의 5%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-16", + "number": "12-16", + "name": "맨홀", + "level": 2, + "parent_code": "FP-12", + "sort_order": 98304, + "tables": [ + { + "pum_table_id": "F0354", + "section": "12-16. 맨홀", + "source_line": 6478, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "구체콘크리트 (레미콘)", + "봉상후렉시블(45mm)", + "버림콘크리트 (레미콘)", + "원형거푸집 (PE10회)", + "설치비", + "보통인부", + "벽체", + "거 푸 집", + "철 근", + "사다리설치" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "구체콘크리트 (레미콘)", + "철근", + "㎥", + "", + "콘크리트공0.17인/㎥,보통인부 0.29인/㎥", + "" + ], + [ + "봉상후렉시블(45mm)", + "대", + "1", + "Q=5.4㎥/hr", + "", + "" + ], + [ + "버림콘크리트 (레미콘)", + "무근", + "㎥", + "", + "콘크리트공0.15인/㎥,보통인부 0.27인/㎥", + "" + ], + [ + "원형거푸집 (PE10회)", + "자재비", + "벽체,스라브 및 기초", + "조", + "", + "각 1조/10" + ], + [ + "설치비", + "기초및 슬라브", + "특별인부", + "인", + "", + "특별인부0.16인/㎡, 보통인부 0.45인/㎡" + ], + [ + "보통인부", + "인", + "", + "", + "", + "" + ], + [ + "벽체", + "특별인부", + "인", + "", + "특별인부0.20인/㎡, 보통인부 0.60인/㎡", + "" + ], + [ + "보통인부", + "인", + "", + "", + "", + "" + ], + [ + "거 푸 집", + "합판4회", + "㎡", + "", + "", + "" + ], + [ + "철 근", + "철근가공조립(보통)", + "ton", + "", + "", + "" + ], + [ + "설치비", + "", + "%", + "5", + "재료비의 5%", + "" + ], + [ + "사다리설치", + "∅19m/m 아연도금 및 스테인레스", + "ton", + "", + "철근가공조립(간단) 준용", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-17", + "number": "12-17", + "name": "펌프카", + "level": 2, + "parent_code": "FP-12", + "sort_order": 98560, + "tables": [] + }, + { + "work_item_code": "FP-12-17-01", + "number": "12-17-1", + "name": "펌프카 타설", + "level": 3, + "parent_code": "FP-12-17", + "sort_order": 98816, + "tables": [ + { + "pum_table_id": "F0355", + "section": "12-17-1. 펌프카 타설", + "source_line": 6499, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "콘 크 리 트 공", + "특 별 인 부", + "보 통 인 부" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "무근콘크리트", + "콘 크 리 트 공", + "특 별 인 부", + "보 통 인 부", + "콘크리트펌프차" + ], + "condition_note": [ + "구 분", + "단 위", + "작 업 조", + "비 고" + ], + "raw_row": [ + [ + "무근콘크리트", + "철근콘크리트", + "", + "", + "" + ], + [ + "콘 크 리 트 공", + "인", + "3", + "4", + "타설/진동기/면정리 배관 타설 : 1인 추가 현장 정리/보조" + ], + [ + "특 별 인 부", + "인", + "2", + "2", + "" + ], + [ + "보 통 인 부", + "인", + "1", + "1", + "" + ], + [ + "콘크리트펌프차", + "대", + "1대(80㎥/시간 이상)", + "시공 조건에 따른 규격 선정", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-17-02", + "number": "12-17-2", + "name": "철근, 펌프카 0-15m", + "level": 3, + "parent_code": "FP-12-17", + "sort_order": 99072, + "tables": [] + }, + { + "work_item_code": "FP-12-17-02", + "number": "12-17-2", + "name": "무근진동기 제외", + "level": 3, + "parent_code": "FP-12-17", + "sort_order": 99328, + "tables": [ + { + "pum_table_id": "F0363", + "section": "12-17-2. 철근, 펌프카 0-15m", + "source_line": 6578, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "구체콘크리트 (철근)", + "타설", + "보통인부", + "펌프카(80㎥/hr)", + "콘크리트다짐" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "구체콘크리트 (철근)", + "레미콘", + "㎥", + "1", + "별도계산" + ], + [ + "타설", + "콘크리트공", + "인", + "0.07", + "" + ], + [ + "보통인부", + "인", + "0.05", + "", + "" + ], + [ + "펌프카(80㎥/hr)", + "hr", + "0.0369", + "Q=27.1㎥/hr", + "" + ], + [ + "콘크리트다짐", + "%", + "1", + "기계경비+인건비의", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-18", + "number": "12-18", + "name": "철근가공조립(복잡)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 99584, + "tables": [ + { + "pum_table_id": "F0365", + "section": "12-18. 철근가공조립(복잡)", + "source_line": 6603, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "ton", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "철 근 공" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "결속선(R=0.9mm)", + "철 근 공", + "보통인부", + "기구손료(노무비의)" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "결속선(R=0.9mm)", + "kg", + "8.0", + "" + ], + [ + "철 근 공", + "인", + "4.2", + "" + ], + [ + "보통인부", + "인", + "2.4", + "" + ], + [ + "기구손료(노무비의)", + "%", + "2", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-19", + "number": "12-19", + "name": "강관비계", + "level": 2, + "parent_code": "FP-12", + "sort_order": 99840, + "tables": [ + { + "pum_table_id": "F0366", + "section": "12-19. 강관비계", + "source_line": 6614, + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "철 물" + ], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "이음철물", + "조임철물", + "받침철물", + "철 물", + "인력", + "경비" + ], + "condition_note": [ + "구 분", + "단 위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "강관(∅48.6mm×2.4mm)", + "m", + "3.99", + "" + ], + [ + "이음철물", + "개", + "0.5", + "", + "" + ], + [ + "조임철물", + "개", + "2.08", + "", + "" + ], + [ + "받침철물", + "개", + "0.04", + "", + "" + ], + [ + "철 물", + "개", + "0.04", + "", + "" + ], + [ + "인력", + "비 계 공", + "인", + "0.10", + "" + ], + [ + "경비", + "기구손료(노무비의)", + "%", + "5", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-20", + "number": "12-20", + "name": "강관동바리", + "level": 2, + "parent_code": "FP-12", + "sort_order": 100096, + "tables": [ + { + "pum_table_id": "F0367", + "section": "12-20. 강관동바리", + "source_line": 6630, + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "외관(60.6mm×2.3mm)", + "잡재료비(재료비의)", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "강관 동바리", + "내관(48.6mm×2.4mm)", + "본", + "0.38", + "" + ], + [ + "외관(60.6mm×2.3mm)", + "본", + "0.38", + "", + "", + "" + ], + [ + "잡재료비(재료비의)", + "%", + "5", + "", + "", + "" + ], + [ + "인력", + "형틀목공", + "인", + "0.07", + "", + "" + ], + [ + "보통인부", + "인", + "0.05", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-21", + "number": "12-21", + "name": "아스팔트코팅(2회)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 100352, + "tables": [ + { + "pum_table_id": "F0368", + "section": "12-21. 아스팔트코팅(2회)", + "source_line": 6644, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "아스팔트(㏊-500)", + "kg", + "4.0", + "2.0×2회" + ], + [ + "인력", + "방수공", + "인", + "0.034", + "0.017×2회" + ], + [ + "보통인부", + "인", + "0.04", + "0.02×2회", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-22", + "number": "12-22", + "name": "P.V.C 파이프 설치(50mm)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 100608, + "tables": [ + { + "pum_table_id": "F0369", + "section": "12-22. P.V.C 파이프 설치(50mm)", + "source_line": 6654, + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료비", + "설치비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료비", + "", + "" + ], + [ + "설치비", + "재료비의 5%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-23", + "number": "12-23", + "name": "부직포 설치", + "level": 2, + "parent_code": "FP-12", + "sort_order": 100864, + "tables": [ + { + "pum_table_id": "F0370", + "section": "12-23. 부직포설치", + "source_line": 6663, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "잡재료비(재료비의)", + "인력" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "부 직 포", + "㎡", + "1.05", + "재료할증 5%" + ], + [ + "잡재료비(재료비의)", + "%", + "2", + "", + "" + ], + [ + "인력", + "보통인부", + "인", + "0.003", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-24", + "number": "12-24", + "name": "뒷채움 및 되메우기", + "level": 2, + "parent_code": "FP-12", + "sort_order": 101120, + "tables": [] + }, + { + "work_item_code": "FP-12-24-01", + "number": "12-24-1", + "name": "뒷채움", + "level": 3, + "parent_code": "FP-12-24", + "sort_order": 101376, + "tables": [] + }, + { + "work_item_code": "FP-12-24-02", + "number": "12-24-2", + "name": "되메우기(노상재)", + "level": 3, + "parent_code": "FP-12-24", + "sort_order": 101632, + "tables": [ + { + "pum_table_id": "F0372", + "section": "12-24-2. 되메우기(노상재)", + "source_line": 6691, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 100.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "0.9/1.30" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "뒷채움 자재비 및 운반비 별산", + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "f", + "E", + "㎝(sec)", + "살수", + "다짐", + "타이어 로울러(8-15ton)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "뒷채움 자재비 및 운반비 별산", + "", + "", + "", + "", + "" + ], + [ + "인력 (10%)", + "특별인부(인)", + "0.2", + "", + "", + "" + ], + [ + "보통인부(인)", + "4.0", + "", + "", + "", + "" + ], + [ + "장비 (90%)", + "부설", + "유압식백호우 (무한궤도 0.7㎥)", + "k", + "0.9", + "" + ], + [ + "f", + "0.69", + "0.9/1.30", + "", + "", + "" + ], + [ + "E", + "0.60", + "", + "", + "", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "", + "", + "" + ], + [ + "살수", + "물탱크(5,500ℓ)", + "", + "", + "", + "" + ], + [ + "다짐", + "진동 로울러(10ton)", + "", + "", + "", + "" + ], + [ + "타이어 로울러(8-15ton)", + "", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-25", + "number": "12-25", + "name": "기초잡석", + "level": 2, + "parent_code": "FP-12", + "sort_order": 101888, + "tables": [ + { + "pum_table_id": "F0373", + "section": "12-25. 기초잡석", + "source_line": 6708, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "0.2 × 30%" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "K", + "E", + "f", + "㎝(sec)" + ], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": true, + "variant_key": [ + "소할(30%)", + "적사", + "E", + "f", + "㎝(sec)", + "운반", + "부설 및 다짐" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "소할(30%)", + "할석공(인)", + "0.2 × 30%", + "브레이커 사용할 때 제외", + "" + ], + [ + "적사", + "유압식백호우 (무한궤도 0.7㎥)", + "K", + "0.55", + "" + ], + [ + "E", + "0.35", + "", + "", + "" + ], + [ + "f", + "1.0", + "", + "", + "" + ], + [ + "㎝(sec)", + "22(180°)", + "", + "", + "" + ], + [ + "운반", + "덤프트럭(15ton)", + "", + "", + "" + ], + [ + "부설 및 다짐", + "보통인부(인)", + "0.6", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-26", + "number": "12-26", + "name": "물푸기", + "level": 2, + "parent_code": "FP-12", + "sort_order": 102144, + "tables": [ + { + "pum_table_id": "F0374", + "section": "12-26. 물푸기", + "source_line": 6722, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "기계", + "디젤엔진(15HP)", + "운반 및 설치 (목도운반)", + "보통인부(인)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "기계", + "양수기(150m/m)", + "", + "" + ], + [ + "디젤엔진(15HP)", + "", + "11.19kW", + "" + ], + [ + "운반 및 설치 (목도운반)", + "목도(인력운반공)(인)", + "4", + "L=30m, V=2500m/hr, T=25분" + ], + [ + "보통인부(인)", + "1", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-27", + "number": "12-27", + "name": "지수판", + "level": 2, + "parent_code": "FP-12", + "sort_order": 102400, + "tables": [] + }, + { + "work_item_code": "FP-12-24-01", + "number": "12-24-1", + "name": "지수판 설치", + "level": 3, + "parent_code": "FP-12-24", + "sort_order": 102656, + "tables": [ + { + "pum_table_id": "F0371", + "section": "12-24-1. 뒷채움", + "source_line": 6675, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 100.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [ + "0.95/1.175" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "뒷채움 자재비 및 운반비 별산", + "인력 (10%)", + "보통인부(인)", + "장비 (90%)", + "f", + "E", + "㎝(sec)", + "살수", + "다짐" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "뒷채움 자재비 및 운반비 별산", + "", + "", + "", + "", + "" + ], + [ + "인력 (10%)", + "특별인부(인)", + "0.2", + "", + "", + "" + ], + [ + "보통인부(인)", + "4.0", + "", + "", + "", + "" + ], + [ + "장비 (90%)", + "부설", + "유압식백호우 (무한궤도 0.7㎥)", + "k", + "0.9", + "" + ], + [ + "f", + "0.81", + "0.95/1.175", + "", + "", + "" + ], + [ + "E", + "0.60", + "", + "", + "", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "", + "", + "" + ], + [ + "살수", + "물탱크(5,500ℓ)", + "", + "", + "", + "" + ], + [ + "다짐", + "진동 로울러(10ton)", + "", + "", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-27-02", + "number": "12-27-2", + "name": "JOINT FILLER", + "level": 3, + "parent_code": "FP-12-27", + "sort_order": 102912, + "tables": [ + { + "pum_table_id": "F0376", + "section": "12-27-2. JOINT FILLER", + "source_line": 6748, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "접착제", + "인력(설치비)" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "JOINT FILLER", + "㎡", + "1.1", + "10% 할증" + ], + [ + "접착제", + "kg", + "0.30", + "", + "" + ], + [ + "인력(설치비)", + "보통인부", + "인", + "0.08", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-27-03", + "number": "12-27-3", + "name": "실런트(20×25mm)", + "level": 3, + "parent_code": "FP-12-27", + "sort_order": 103168, + "tables": [ + { + "pum_table_id": "F0377", + "section": "12-27-3. 실런트(20×25mm)", + "source_line": 6758, + "pum_form": "requirement", + "form_basis": "분류 딱지 ['자재']", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "인력(설치비)" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "실런트", + "㎥", + "0.70", + "0.02×0.025×1400kg/㎥" + ], + [ + "인력(설치비)", + "방수공", + "인", + "0.04", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-28", + "number": "12-28", + "name": "신구 BOX접합", + "level": 2, + "parent_code": "FP-12", + "sort_order": 103424, + "tables": [ + { + "pum_table_id": "F0378", + "section": "12-28. 신구 BOX접합", + "source_line": 6767, + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "시너", + "인력" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "에폭시 접착제", + "kg", + "1.20", + "" + ], + [ + "시너", + "ℓ", + "0.21", + "", + "" + ], + [ + "인력", + "미장공", + "인", + "0.12", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-29", + "number": "12-29", + "name": "스페이셔 설치(몰탈 블록)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 103680, + "tables": [ + { + "pum_table_id": "F0379", + "section": "12-29. 스페이셔 설치(몰탈 블록)", + "source_line": 6775, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료비", + "설치비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료비", + "", + "필요수량" + ], + [ + "설치비", + "재료비의 5%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-30", + "number": "12-30", + "name": "다웰바 설치", + "level": 2, + "parent_code": "FP-12", + "sort_order": 103936, + "tables": [] + }, + { + "work_item_code": "FP-12-30-01", + "number": "12-30-1", + "name": "신축 이음부", + "level": 3, + "parent_code": "FP-12-30", + "sort_order": 104192, + "tables": [ + { + "pum_table_id": "F0380", + "section": "12-30-1. 신축 이음부(D32m/m, L=1000m/m)", + "source_line": 6786, + "pum_form": "reference", + "form_basis": "'별도계상' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "철근가공조립(간단)", + "P.V.C 파이프(Ø35mm)", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "강봉", + "EA", + "별도계상", + "" + ], + [ + "철근가공조립(간단)", + "ton", + "강봉수량", + "", + "" + ], + [ + "P.V.C 파이프(Ø35mm)", + "m", + "", + "", + "" + ], + [ + "인력", + "특별인부", + "인", + "0.1", + "" + ], + [ + "보통인부", + "인", + "0.001", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-30-02", + "number": "12-30-2", + "name": "접속스라이부", + "level": 3, + "parent_code": "FP-12-30", + "sort_order": 104448, + "tables": [ + { + "pum_table_id": "F0381", + "section": "12-30-2. 접속스라브부(D29m/m, L=500m/m)", + "source_line": 6798, + "pum_form": "reference", + "form_basis": "'별도계상' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [ + "150×150×15" + ], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "재료", + "탄성고무받침", + "스티로폴", + "TAR PAPER", + "채움재(아스팔트)", + "다웰바캡", + "조립", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "규격", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료", + "원형철근", + "∅29m/m, L=500m/m", + "ton", + "", + "별도계상" + ], + [ + "탄성고무받침", + "150×150×15", + "㎡", + "", + "", + "" + ], + [ + "스티로폴", + "T=20m/m", + "㎡", + "", + "", + "" + ], + [ + "TAR PAPER", + "T=15m/m", + "㎡", + "", + "", + "" + ], + [ + "채움재(아스팔트)", + "", + "㎥", + "0.001", + "", + "" + ], + [ + "다웰바캡", + "D=50m/m", + "m", + "", + "", + "" + ], + [ + "조립", + "철근가공조립", + "보통", + "ton", + "", + "" + ], + [ + "인력", + "특별인부", + "", + "인", + "0.1", + "" + ], + [ + "보통인부", + "", + "인", + "0.001", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-31", + "number": "12-31", + "name": "물끊기 홈(NOTCH) 설치", + "level": 2, + "parent_code": "FP-12", + "sort_order": 104704, + "tables": [ + { + "pum_table_id": "F0382", + "section": "12-31. 물끊기 홈(NOTCH) 설치", + "source_line": 6814, + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료비", + "설치비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료비", + "", + "" + ], + [ + "설치비", + "주재료비의 5%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-32", + "number": "12-32", + "name": "전선관 설치(P.V.C ø54m/m)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 104960, + "tables": [ + { + "pum_table_id": "F0383", + "section": "12-32. 전선관 설치(P.V.C ø54m/m)", + "source_line": 6823, + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료비", + "설치비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료비", + "P.V.C ∅54m/m", + "" + ], + [ + "설치비", + "주재료비의 10%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-33", + "number": "12-33", + "name": "비닐깔기", + "level": 2, + "parent_code": "FP-12", + "sort_order": 105216, + "tables": [ + { + "pum_table_id": "F0384", + "section": "12-33. 비닐깔기", + "source_line": 6832, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료", + "인력" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료", + "t=0.08m/m", + "㎡", + "0.08", + "" + ], + [ + "인력", + "보통인부", + "인", + "0.004", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-34", + "number": "12-34", + "name": "암거이음받침", + "level": 2, + "parent_code": "FP-12", + "sort_order": 105472, + "tables": [] + }, + { + "work_item_code": "FP-12-34-01", + "number": "12-34-1", + "name": "콘크리트 타설(철근 진동기 포함)", + "level": 3, + "parent_code": "FP-12-34", + "sort_order": 105728, + "tables": [ + { + "pum_table_id": "F0385", + "section": "12-34-1. 콘크리트 타설(철근 진동기포함)", + "source_line": 6843, + "pum_form": "reference", + "form_basis": "'별도계상' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "개소", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "인력", + "보통인부", + "기계", + "봉상후렉시블(45mm)" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "콘크리트(레미콘)", + "㎥", + "", + "별도계상" + ], + [ + "인력", + "콘크리트공", + "인", + "0.17", + "" + ], + [ + "보통인부", + "인", + "0.29", + "", + "" + ], + [ + "기계", + "콘크리트 진동기(3.5HP)", + "대", + "2", + "(Q=5.4㎥/hr)" + ], + [ + "봉상후렉시블(45mm)", + "대", + "2", + "(Q=5.4㎥/hr)", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-34-02", + "number": "12-34-2", + "name": "합판거푸집(3회 0~7m)", + "level": 3, + "parent_code": "FP-12-34", + "sort_order": 105984, + "tables": [ + { + "pum_table_id": "F0386", + "section": "12-34-2. 합판거푸집(3회 0~7m)", + "source_line": 6855, + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '회기준' — 값이 아니라 비율 지시", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료비", + "노무비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료비", + "1회 기준 46.1%", + "" + ], + [ + "노무비", + "1회 기준 47.1%", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-34-03", + "number": "12-34-3", + "name": "철근가공조립", + "level": 3, + "parent_code": "FP-12-34", + "sort_order": 106240, + "tables": [ + { + "pum_table_id": "F0387", + "section": "12-34-3. 철근가공조립(보통)", + "source_line": 6864, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "ton", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "인력", + "보통인부", + "고철대(감)", + "철근", + "운반" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "결속선(R-0.9mm)", + "kg", + "6.5", + "" + ], + [ + "인력", + "철근공", + "인", + "3.8", + "" + ], + [ + "보통인부", + "인", + "2.2", + "", + "" + ], + [ + "고철대(감)", + "%", + "3", + "", + "" + ], + [ + "철근", + "이형철근", + "ton", + "별도계상", + "" + ], + [ + "운반", + "철근", + "ton", + "별도계상", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-34-04", + "number": "12-34-4", + "name": "채움재", + "level": 3, + "parent_code": "FP-12-34", + "sort_order": 106496, + "tables": [ + { + "pum_table_id": "F0388", + "section": "12-34-4. 채움재(T=20m/m)", + "source_line": 6877, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "재료비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "재료비", + "JOINT FILLER", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-35", + "number": "12-35", + "name": "콘크리트 표면 강화재(하드너)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 106752, + "tables": [ + { + "pum_table_id": "F0389", + "section": "12-35. 콘크리트 표면 강화재(하드너)", + "source_line": 6885, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "설치", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "", + "", + "필요수량", + "" + ], + [ + "설치", + "미장공", + "인", + "0.14", + "" + ], + [ + "보통인부", + "인", + "0.05", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-36", + "number": "12-36", + "name": "P.C BOX 설치", + "level": 2, + "parent_code": "FP-12", + "sort_order": 107008, + "tables": [ + { + "pum_table_id": "F0390", + "section": "12-36. P.C BOX 설치", + "source_line": 6895, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "제작비", + "운송비", + "설치비" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "제작비", + "견적처리", + "" + ], + [ + "운송비", + "견적처리", + "" + ], + [ + "설치비", + "견적처리", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-37", + "number": "12-37", + "name": "콘크리트 타설(무근진동기 제외)", + "level": 2, + "parent_code": "FP-12", + "sort_order": 107264, + "tables": [ + { + "pum_table_id": "F0391", + "section": "12-37. 콘크리트 타설(무근진동기 제외)", + "source_line": 6905, + "pum_form": "reference", + "form_basis": "'별도계상' — 값이 아니라 참조 지시", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "인력", + "보통인부" + ], + "condition_note": [ + "구 분", + "단위", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "자재", + "콘크리트", + "", + "", + "레미콘(별도계상)" + ], + [ + "인력", + "콘크리트공", + "인", + "0.15", + "" + ], + [ + "보통인부", + "인", + "0.27", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-38", + "number": "12-38", + "name": "유로폼", + "level": 2, + "parent_code": "FP-12", + "sort_order": 107520, + "tables": [] + }, + { + "work_item_code": "FP-12-38-01", + "number": "12-38-1", + "name": "사용횟수", + "level": 3, + "parent_code": "FP-12-38", + "sort_order": 107776, + "tables": [ + { + "pum_table_id": "F0392", + "section": "12-38-1. 사용횟수", + "source_line": 6915, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "패 널 류 보, 드롭헤드, 강관파이프, 훅  클래프, 웨지핀" + ], + "condition_note": [ + "구 분", + "사용 조작 회수" + ], + "raw_row": [ + [ + "패 널 류 보, 드롭헤드, 강관파이프, 훅  클래프, 웨지핀", + "12회 사용 잔존율 25% 25회 사용 잔존율 10%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-38-02", + "number": "12-38-2", + "name": "사용수량", + "level": 3, + "parent_code": "FP-12-38", + "sort_order": 108032, + "tables": [ + { + "pum_table_id": "F0393", + "section": "12-38-2. 사용수량", + "source_line": 6925, + "pum_form": "reference", + "form_basis": "'적용한다' — 값이 아니라 참조 지시", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "패 널", + "내 부 패 널" + ], + "formula_rows": [], + "special_glyphs": [ + "x(U+0078)" + ], + "capacity_formula_here": false, + "variant_key": [ + "패 널", + "내 부 패 널", + "부 자 재 (웨지핀, 플랫타이, 강관파이프, 훅 등)", + "소모자재(박리제 등)" + ], + "condition_note": [ + "구 분", + "규 격", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "패 널", + "600 x 1,200mm", + "매", + "0.89" + ], + [ + "내 부 패 널", + "(200+200) x 1,200mm", + "매", + "0.03" + ], + [ + "부 자 재 (웨지핀, 플랫타이, 강관파이프, 훅 등)", + "주자재비의", + "%", + "설치 유형에 따라 다음 주자재비의 다음 요율을 적용한다. 구 분간 단보 통복 잡요 율24%52%79% 구 분 간 단 보 통 복 잡 요 율 24% 52% 79%" + ], + [ + "소모자재(박리제 등)", + "주자재비의", + "%", + "5 %" + ] + ] + }, + { + "pum_table_id": "F0394", + "section": "12-38-2. 사용수량", + "source_line": 6932, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "요 율" + ], + "condition_note": [ + "구 분", + "간 단", + "보 통", + "복 잡" + ], + "raw_row": [ + [ + "요 율", + "24%", + "52%", + "79%" + ] + ] + } + ] + }, + { + "work_item_code": "FP-12-38-03", + "number": "12-38-3", + "name": "설치 및 해체", + "level": 3, + "parent_code": "FP-12-38", + "sort_order": 108288, + "tables": [ + { + "pum_table_id": "F0395", + "section": "12-38-3. 설치 및 해체", + "source_line": 6943, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "복 잡", + "형틀목공 보통인부", + "비 고" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량", + "시 공 량 (㎡)" + ], + "raw_row": [ + [ + "복 잡", + "보 통", + "간 단", + "", + "", + "" + ], + [ + "형틀목공 보통인부", + "인 인", + "4 1", + "25", + "35", + "40" + ], + [ + "비 고", + "∙ 현장 여건(고소작업, 거푸집 적재공간 협소 등)에 따라 상시적인 크레인을 활용한 시공이 필요한 경우 해당 장비를 작업조에 추가하여 계상하고, 시공량은 감하지 않는다. ∙ 본 품은 수직고 7m까지 적용하며, 양중장비를 활용하지 않는다. ∙ 수직고가 7m를 초과하는 경우 매 3m마다 시공량을 9%까지 감한다.", + "", + "", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0396", + "section": "12-38-3. 설치 및 해체", + "source_line": 6954, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "복 잡", + "보 통", + "간 단" + ], + "condition_note": [ + "구 분", + "유 형" + ], + "raw_row": [ + [ + "복 잡", + "토목 : 교대, 날개벽 등 복잡하고 보강이 많은 구조 건축 : 외부 벽체, 보/기둥" + ], + [ + "보 통", + "측구, 수로, 옹벽, 일반적인 벽체, 박스 등" + ], + [ + "간 단", + "수문 또는 관의 기초, 건축 매트기초 등 간단한 구조" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13", + "number": "13", + "name": "구조물", + "level": 1, + "parent_code": null, + "sort_order": 108544, + "tables": [] + }, + { + "work_item_code": "FP-13-01", + "number": "13-1", + "name": "석재 및 골재의 분류", + "level": 2, + "parent_code": "FP-13", + "sort_order": 108800, + "tables": [] + }, + { + "work_item_code": "FP-13-02", + "number": "13-2", + "name": "채집 및 세척", + "level": 2, + "parent_code": "FP-13", + "sort_order": 109056, + "tables": [] + }, + { + "work_item_code": "FP-13-02-01", + "number": "13-2-1", + "name": "모래, 자갈, 약돌 채집", + "level": 3, + "parent_code": "FP-13-02", + "sort_order": 109312, + "tables": [ + { + "pum_table_id": "F0397", + "section": "13-2-1. 모래, 자갈, 약돌 채집", + "source_line": 6972, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "규격", + "보통인부(인)" + ], + "condition_note": [ + "명 칭", + "친모래", + "자갈", + "자갈", + "조약돌", + "막자갈", + "비 고" + ], + "raw_row": [ + [ + "규격", + "", + "25mm까지", + "40mm까지", + "150mm내외", + "", + "" + ], + [ + "보통인부(인)", + "0.5", + "1.44", + "1.0", + "0.6", + "0.3", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-02-02", + "number": "13-2-2", + "name": "막돌 채집", + "level": 3, + "parent_code": "FP-13-02", + "sort_order": 109568, + "tables": [ + { + "pum_table_id": "F0398", + "section": "13-2-2. 막돌 채집", + "source_line": 6986, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통인부", + "㎥당" + ], + "condition_note": [ + "구 분", + "막 돌", + "비 고" + ], + "raw_row": [ + [ + "보통인부", + "㎡당", + "0.17", + "현지의 조건에 따라 전석의 소할(小割)을 필요로 할 경우에는 ㎥당 할석공 0.2인을 할증한다." + ], + [ + "㎥당", + "0.64", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-02-03", + "number": "13-2-3", + "name": "큰돌 채집", + "level": 3, + "parent_code": "FP-13-02", + "sort_order": 109824, + "tables": [ + { + "pum_table_id": "F0399", + "section": "13-2-3. 큰돌 채집", + "source_line": 6995, + "pum_form": "requirement", + "form_basis": "'수량'", + "basis_quantity": 100.0, + "basis_unit": "개", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작업반장", + "굴착기", + "집게 장치", + "(회전식 돌집게) 굴착기 운전", + "제 잡비 비율" + ], + "condition_note": [ + "명 칭", + "규 격", + "단위", + "수량", + "비 고" + ], + "raw_row": [ + [ + "작업반장", + "", + "인", + "2.6", + "" + ], + [ + "굴착기", + "굴착기 유압식(평적 0.6㎥)", + "시간 (h)", + "13.4", + "굴삭 · 석재 채취 기계" + ], + [ + "집게 장치", + "1m 급", + "시간 (h)", + "13.4", + "큰돌 선별 · 짐싣는 기계 (스톤그랩)" + ], + [ + "(회전식 돌집게) 굴착기 운전", + "굴착기 유압식(평적 0.6㎥)", + "", + "", + "" + ], + [ + "제 잡비 비율", + "", + "%", + "6", + "" + ] + ] + }, + { + "pum_table_id": "F0400", + "section": "13-2-3. 큰돌 채집", + "source_line": 7006, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "40㎝이상∼60㎝미만", + "뒷길이" + ], + "condition_note": [ + "명칭", + "단위", + "규격(직경)" + ], + "raw_row": [ + [ + "40㎝이상∼60㎝미만", + "60㎝이상∼80㎝미만", + "80㎝이상∼100㎝이하", + "", + "" + ], + [ + "뒷길이", + "㎝", + "60 ~ 75", + "75 ~ 95", + "95 ~ 120" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-02-04", + "number": "13-2-4", + "name": "야면석 채집(인력)", + "level": 3, + "parent_code": "FP-13-02", + "sort_order": 110080, + "tables": [ + { + "pum_table_id": "F0401", + "section": "13-2-4. 야면석 채집(인력)", + "source_line": 7025, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "인", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인 부", + "㎥당" + ], + "condition_note": [ + "뒷 길 이(㎝)", + "25", + "35", + "45", + "55", + "60" + ], + "raw_row": [ + [ + "인 부", + "㎡당", + "0.11", + "0.17", + "0.22", + "0.28", + "0.36" + ], + [ + "㎥당", + "0.60", + "0.64", + "0.67", + "0.70", + "0.80", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-03", + "number": "13-3", + "name": "기초다짐 및 뒷채움", + "level": 2, + "parent_code": "FP-13", + "sort_order": 110336, + "tables": [ + { + "pum_table_id": "F0405", + "section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", + "source_line": 7087, + "pum_form": "requirement", + "form_basis": "값 단위 '(m)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "증가율(%)" + ], + "condition_note": [ + "높 이(m)", + "3 ∼ 4까지", + "4 ∼ 5.5까지", + "5.5∼7.5까지", + "7.5초과" + ], + "raw_row": [ + [ + "증가율(%)", + "30", + "40", + "60", + "80 ∼ 100" + ] + ] + }, + { + "pum_table_id": "F0406", + "section": "13-3. 기초다짐 및 뒤채움’ 항을 적용한다.", + "source_line": 7094, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "×(U+00D7)" + ], + "capacity_formula_here": false, + "variant_key": [ + "25㎝(17×17)", + "30㎝(20×20)", + "35㎝(25×25)", + "45㎝(30×30)", + "55㎝(35×35)", + "60㎝(40×40)", + "75㎝(50×50)" + ], + "condition_note": [ + "종별 뒷길이 단위", + "견치돌", + "깬돌 및 깬잡석", + "야면석" + ], + "raw_row": [ + [ + "25㎝(17×17)", + "개 ㎏", + "32 192", + "33 132", + "- -" + ], + [ + "30㎝(20×20)", + "개 ㎏", + "23 368", + "24 264", + "28 420" + ], + [ + "35㎝(25×25)", + "개 ㎏", + "16 480", + "17 340", + "23 575" + ], + [ + "45㎝(30×30)", + "개 ㎏", + "11 627", + "12 480", + "16 880" + ], + [ + "55㎝(35×35)", + "개 ㎏", + "8 752", + "9 504", + "11 1,100" + ], + [ + "60㎝(40×40)", + "개 ㎏", + "6 822", + "6 540", + "- -" + ], + [ + "75㎝(50×50)", + "개 ㎏", + "4 1,028", + "4 560", + "- -" + ] + ] + }, + { + "pum_table_id": "F0418", + "section": "13-3. 기초다짐 및 뒤채움”항을 적용한다.", + "source_line": 7268, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "35cm 이하", + "석공 보통인부", + "굴착기+부착용 집게" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수량(뒷길이)" + ], + "raw_row": [ + [ + "35cm 이하", + "55cm 이하", + "75cm 이하", + "", + "", + "" + ], + [ + "석공 보통인부", + "", + "인 〃", + "0.11 0.04", + "0.10 0.03", + "0.09 0.02" + ], + [ + "굴착기+부착용 집게", + "0.6㎥", + "시간", + "0.22", + "0.21", + "0.20" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-03-01", + "number": "13-3-1", + "name": "인력", + "level": 3, + "parent_code": "FP-13-03", + "sort_order": 110592, + "tables": [ + { + "pum_table_id": "F0402", + "section": "13-3-1. 인력", + "source_line": 7039, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "모래 기초다짐", + "두 께 3㎝", + "두 께 6㎝", + "자갈 기초다짐 지름 1~3㎝", + "조약돌 기초다짐 지름 9~15㎝", + "돌쌓기 뒤채움 지름 9~15㎝" + ], + "condition_note": [ + "종 별", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "모래 기초다짐", + "", + "" + ], + [ + "두 께 3㎝", + "0.5", + "10㎡당 0.15인" + ], + [ + "두 께 6㎝", + "0.4", + "10㎡당 0.24인" + ], + [ + "자갈 기초다짐 지름 1~3㎝", + "0.5", + "" + ], + [ + "조약돌 기초다짐 지름 9~15㎝", + "0.5-0.7", + "" + ], + [ + "돌쌓기 뒤채움 지름 9~15㎝", + "0.5-0.8", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-03-02", + "number": "13-3-2", + "name": "기계", + "level": 3, + "parent_code": "FP-13-03", + "sort_order": 110848, + "tables": [ + { + "pum_table_id": "F0403", + "section": "13-3-2. 기계", + "source_line": 7054, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "(mm)", + "기초다짐 뒷채움", + "75이상" + ], + "condition_note": [ + "종 별", + "규격", + "보통인부", + "굴착기(0.2㎥)", + "살수차(5,500ℓ)", + "플레이트콤팩트 (1.54ton)" + ], + "raw_row": [ + [ + "(mm)", + "(인)", + "(hr)", + "(hr)", + "(hr)", + "" + ], + [ + "기초다짐 뒷채움", + "75미만", + "0.019", + "0.076", + "0.019", + "0.115" + ], + [ + "75이상", + "0.022", + "0.087", + "0.022", + "0.132", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-04", + "number": "13-4", + "name": "돌쌓기", + "level": 2, + "parent_code": "FP-13", + "sort_order": 111104, + "tables": [] + }, + { + "work_item_code": "FP-13-04-01", + "number": "13-4-1", + "name": "메쌓기(인력)", + "level": 3, + "parent_code": "FP-13-04", + "sort_order": 111360, + "tables": [ + { + "pum_table_id": "F0404", + "section": "13-4-1. 메쌓기(인력)", + "source_line": 7069, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "골쌓기", + "석공 (인)", + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "condition_note": [ + "뒷길이 (㎝)", + "견 치 돌", + "깬 돌", + "깬 잡 석", + "호박돌 및 야면석" + ], + "raw_row": [ + [ + "골쌓기", + "켜쌓기", + "골쌓기", + "켜쌓기", + "골쌓기", + "켜쌓기", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "석공 (인)", + "보통인부 (인)", + "석공 (인)", + "보통인부 (인)", + "석 공 (인)", + "보통인부 (인)", + "석 공 (인)", + "보 통 인 부 (인)", + "석공 (인)", + "보통인부 (인)", + "석 공 (인)", + "보통인부 (인)", + "석공 (인)", + "보통인부 (인)", + "" + ], + [ + "25", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "0.15", + "0.12", + "0.13", + "0.10", + "0.10", + "0.10" + ], + [ + "30", + "-", + "-", + "-", + "-", + "0.26", + "0.21", + "0.23", + "0.18", + "0.22", + "0.18", + "0.20", + "0.16", + "0.13", + "0.11" + ], + [ + "35", + "0.50", + "0.40", + "0.55", + "0.44", + "0.30", + "0.24", + "0.27", + "0.22", + "0.25", + "0.20", + "0.23", + "0.18", + "0.16", + "0.14" + ], + [ + "45", + "0.60", + "0.48", + "0.66", + "0.53", + "0.36", + "0.29", + "0.32", + "0.25", + "0.30", + "0.24", + "0.27", + "0.22", + "0.24", + "0.21" + ], + [ + "55", + "0.72", + "0.58", + "0.80", + "0.64", + "0.43", + "0.34", + "0.39", + "0.31", + "0.36", + "0.29", + "0.33", + "0.26", + "0.32", + "0.29" + ], + [ + "60", + "0.86", + "0.69", + "0.95", + "0.76", + "0.52", + "0.42", + "0.47", + "0.37", + "0.43", + "0.34", + "0.39", + "0.31", + "0.37", + "0.33" + ], + [ + "75", + "-", + "-", + "-", + "-", + "0.68", + "0.54", + "0.61", + "0.49", + "0.56", + "0.45", + "0.51", + "0.41", + "-", + "-" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-04-02", + "number": "13-4-2", + "name": "메쌓기(장비)", + "level": 3, + "parent_code": "FP-13-04", + "sort_order": 111616, + "tables": [ + { + "pum_table_id": "F0407", + "section": "13-4-2. 메쌓기(장비)", + "source_line": 7110, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "35cm 이하", + "석공 보통인부", + "굴착기+부착용 집게" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수량(뒷길이)" + ], + "raw_row": [ + [ + "35cm 이하", + "55cm 이하", + "75cm 이하", + "", + "", + "" + ], + [ + "석공 보통인부", + "", + "인 〃", + "0.10 0.05", + "0.09 0.04", + "0.08 0.03" + ], + [ + "굴착기+부착용 집게", + "0.6㎥", + "시간", + "0.39", + "0.37", + "0.35" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-04-03", + "number": "13-4-3", + "name": "고임돌 소요량", + "level": 3, + "parent_code": "FP-13-04", + "sort_order": 111872, + "tables": [ + { + "pum_table_id": "F0408", + "section": "13-4-3. 고임돌 소요량", + "source_line": 7128, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "야면석(㎥) 깬잡석(㎥) 깬 돌(㎥) 견치돌(㎥)" + ], + "condition_note": [ + "뒷길이 종별", + "25㎝", + "30㎝", + "35㎝", + "45㎝", + "55㎝", + "60㎝", + "75㎝" + ], + "raw_row": [ + [ + "야면석(㎥) 깬잡석(㎥) 깬 돌(㎥) 견치돌(㎥)", + "0.06 0.09 - -", + "0.07 0.11 0.10 -", + "0.09 0.13 0.12 0.12", + "0.11 0.16 0.15 0.15", + "0.14 0.19 0.18 0.18", + "0.15 0.21 0.20 0.20", + "- 0.26 0.25 0.25" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-04-04", + "number": "13-4-4", + "name": "찰쌓기(인력)", + "level": 3, + "parent_code": "FP-13-04", + "sort_order": 112128, + "tables": [ + { + "pum_table_id": "F0409", + "section": "13-4-4. 찰쌓기(인력)", + "source_line": 7136, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "골쌓기", + "석 공 (인)", + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "condition_note": [ + "뒷길이 (㎝)", + "견 치 돌", + "깬 돌", + "깬 잡 석", + "호박돌 및 야면석" + ], + "raw_row": [ + [ + "골쌓기", + "켜쌓기", + "골쌓기", + "켜쌓기", + "골쌓기", + "켜쌓기", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ], + [ + "석 공 (인)", + "보통인부 (인)", + "석공 (인)", + "보통인부 (인)", + "석 공 (인)", + "보통인부 (인)", + "석공 (인)", + "보통인부 (인)", + "석 공 (인)", + "보통인부 (인)", + "석 공 (인)", + "보통인부 (인)", + "석공 (인)", + "보통인부 (인)", + "" + ], + [ + "25", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "0.12", + "0.12", + "0.10", + "0.10", + "0.08", + "0.10" + ], + [ + "30", + "-", + "-", + "-", + "-", + "0.21", + "0.21", + "0.18", + "0.18", + "0.18", + "0.18", + "0.16", + "0.16", + "0.09", + "0.11" + ], + [ + "35", + "0.40", + "0.40", + "0.44", + "0.44", + "0.24", + "0.24", + "0.22", + "0.22", + "0.20", + "0.20", + "0.18", + "0.18", + "0.11", + "0.14" + ], + [ + "45", + "0.48", + "0.48", + "0.53", + "0.53", + "0.29", + "0.29", + "0.25", + "0.25", + "0.24", + "0.24", + "0.22", + "0.22", + "0.17", + "0.21" + ], + [ + "55", + "0.58", + "0.58", + "0.58", + "0.64", + "0.34", + "0.34", + "0.31", + "0.31", + "0.29", + "0.29", + "0.26", + "0.26", + "0.23", + "0.29" + ], + [ + "60", + "0.69", + "0.69", + "0.69", + "0.76", + "0.42", + "0.42", + "0.37", + "0.37", + "0.34", + "0.34", + "0.31", + "0.31", + "0.26", + "0.33" + ], + [ + "75", + "-", + "-", + "-", + "-", + "0.54", + "0.54", + "0.49", + "0.49", + "0.45", + "0.45", + "0.41", + "0.41", + "-", + "-" + ] + ] + }, + { + "pum_table_id": "F0410", + "section": "13-4-4. 찰쌓기(인력)", + "source_line": 7152, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "야면석(㎥) 호박돌(㎥)", + "깬잡석(㎥) 깬 돌(㎥) 견치돌(㎥)" + ], + "condition_note": [ + "뒷길이 종별", + "25㎝", + "30㎝", + "35㎝", + "45㎝", + "55㎝", + "60㎝", + "75㎝", + "비고" + ], + "raw_row": [ + [ + "야면석(㎥) 호박돌(㎥)", + "0.08 0.08", + "0.10 0.10", + "0.12 0.12", + "0.15 0.15", + "0.18 0.18", + "0.20 0.20", + "0.25 0.25", + "뒷길이 33.3% 〃" + ], + [ + "깬잡석(㎥) 깬 돌(㎥) 견치돌(㎥)", + "0.11 0.11 0.11", + "0.14 0.14 0.14", + "0.16 0.16 0.16", + "0.20 0.20 0.20", + "0.25 0.25 0.25", + "0.27 0.27 0.27", + "0.34 0.34 0.34", + "뒷길이 45% 〃 〃" + ] + ] + }, + { + "pum_table_id": "F0411", + "section": "13-4-4. 찰쌓기(인력)", + "source_line": 7166, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "상부의 두께(㎝) 하부의 두께(㎝)" + ], + "condition_note": [ + "직 고(直高)", + "∼1.5m", + "∼3.0m", + "∼5.0m", + "∼7.0m" + ], + "raw_row": [ + [ + "상부의 두께(㎝) 하부의 두께(㎝)", + "20~40 30~60", + "20~40 45~75", + "20~40 60~100", + "20~40 80~140" + ] + ] + }, + { + "pum_table_id": "F0412", + "section": "13-4-4. 찰쌓기(인력)", + "source_line": 7174, + "pum_form": "requirement", + "form_basis": "값 단위 '(m)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)", + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "∼1.5", + "메쌓기(㎝) 찰쌓기(㎝)" + ], + "condition_note": [ + "높이단위 공종별", + "돌쌓기 높이(m)", + "돌붙이기" + ], + "raw_row": [ + [ + "∼1.5", + "∼3", + "∼5", + "∼7", + "7이상", + "", + "" + ], + [ + "메쌓기(㎝) 찰쌓기(㎝)", + "25~35 25~35", + "36~45 30~35", + "36~60 35~45", + "45~75 35~55", + "75이상 45~60", + "25~60 20~40" + ] + ] + }, + { + "pum_table_id": "F0413", + "section": "13-4-4. 찰쌓기(인력)", + "source_line": 7185, + "pum_form": "requirement", + "form_basis": "값 단위 '(m)'", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "메쌓기", + "절토", + "찰쌓기" + ], + "condition_note": [ + "직 고(直高)(m)", + "∼1.5", + "∼3", + "∼5", + "∼7", + "7이상" + ], + "raw_row": [ + [ + "메쌓기", + "성토", + "1:0.30", + "1:0.35", + "1:0.40", + "1:0.45", + "1:0.50" + ], + [ + "절토", + "1:0.25", + "1:0.30", + "1:0.35", + "1:0.40", + "1:0.45", + "" + ], + [ + "찰쌓기", + "성토", + "1:0.25", + "1:0.30", + "1:0.35", + "1:0.40", + "1:0.45" + ], + [ + "절토", + "1:0.20", + "1:0.25", + "1:0.30", + "1:0.35", + "1:0.40", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-04-05", + "number": "13-4-5", + "name": "찰쌓기(장비)", + "level": 3, + "parent_code": "FP-13-04", + "sort_order": 112384, + "tables": [ + { + "pum_table_id": "F0414", + "section": "13-4-5. 찰쌓기(장비)", + "source_line": 7200, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "35cm 이하", + "석공 보통인부", + "굴착기+부착용 집게" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수량(뒷길이)" + ], + "raw_row": [ + [ + "35cm 이하", + "55cm 이하", + "75cm 이하", + "", + "", + "" + ], + [ + "석공 보통인부", + "", + "인 〃", + "0.09 0.05", + "0.08 0.04", + "0.07 0.03" + ], + [ + "굴착기+부착용 집게", + "0.6㎥", + "시간", + "0.31", + "0.30", + "0.28" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-04-06", + "number": "13-4-6", + "name": "밑돌 및 천단 돌쌓기", + "level": 3, + "parent_code": "FP-13-04", + "sort_order": 112640, + "tables": [ + { + "pum_table_id": "F0415", + "section": "13-4-6. 밑돌 및 천단 돌쌓기", + "source_line": 7219, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "25㎝", + "30㎝", + "35㎝" + ], + "condition_note": [ + "돌의 뒷길이", + "석 공(인)", + "비 고" + ], + "raw_row": [ + [ + "25㎝", + "0.05", + "" + ], + [ + "30㎝", + "0.08", + "" + ], + [ + "35㎝", + "0.09", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-05", + "number": "13-5", + "name": "돌붙임", + "level": 2, + "parent_code": "FP-13", + "sort_order": 112896, + "tables": [] + }, + { + "work_item_code": "FP-13-05-01", + "number": "13-5-1", + "name": "인력", + "level": 3, + "parent_code": "FP-13-05", + "sort_order": 113152, + "tables": [ + { + "pum_table_id": "F0416", + "section": "13-5-1. 인력", + "source_line": 7235, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "종 별", + "뒷길이 (㎝)", + "25 30 35 45 55 60 70" + ], + "condition_note": [ + "구 분", + "메 붙 임", + "찰 붙 임" + ], + "raw_row": [ + [ + "종 별", + "깬 돌", + "깬잡석", + "조약돌 및 야면석", + "깬 돌", + "깬잡석", + "호박돌 및 야면석", + "", + "", + "", + "", + "", + "" + ], + [ + "뒷길이 (㎝)", + "석공 (인)", + "보통 인부 (인)", + "석공 (인)", + "보통 인부 (인)", + "석공 (인)", + "보통 인부 (인)", + "석공 (인)", + "보통 인부 (인)", + "석공 (인)", + "보통 인부 (인)", + "석공 (인)", + "보통 인부 (인)" + ], + [ + "25 30 35 45 55 60 70", + "0.15 0.22 0.25 0.30 0.36 0.43 0.56", + "0.12 0.18 0.20 0.24 0.29 0.34 0.51", + "0.13 0.20 0.23 0.27 0.33 0.39 0.51", + "0.10 0.16 0.18 0.22 0.26 0.31 0.41", + "0.10 0.13 0.16 0.24 0.32 0.37 -", + "0.10 0.11 0.14 0.21 0.29 0.33 -", + "0.12 0.18 0.20 0.24 0.29 0.34 0.45", + "0.12 0.18 0.20 0.24 0.29 0.34 0.45", + "0.10 0.16 0.18 0.22 0.26 0.31 0.41", + "0.10 0.16 0.18 0.22 0.26 0.31 0.41", + "0.08 0.09 0.11 0.17 0.23 0.26 -", + "0.10 0.11 0.14 0.21 0.29 0.33 -" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-05-02", + "number": "13-5-2", + "name": "장비", + "level": 3, + "parent_code": "FP-13-05", + "sort_order": 113408, + "tables": [ + { + "pum_table_id": "F0417", + "section": "13-5-2. 장비", + "source_line": 7252, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "35cm 이하", + "석공 보통인부", + "굴착기+부착용 집게" + ], + "condition_note": [ + "명 칭", + "규 격", + "단 위", + "수량(뒷길이)" + ], + "raw_row": [ + [ + "35cm 이하", + "55cm 이하", + "75cm 이하", + "", + "", + "" + ], + [ + "석공 보통인부", + "", + "인 〃", + "0.13 0.04", + "0.12 0.03", + "0.11 0.02" + ], + [ + "굴착기+부착용 집게", + "0.6㎥", + "시간", + "0.25", + "0.24", + "0.22" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-06", + "number": "13-6", + "name": "큰돌쌓기", + "level": 2, + "parent_code": "FP-13", + "sort_order": 113664, + "tables": [] + }, + { + "work_item_code": "FP-13-06-01", + "number": "13-6-1", + "name": "메쌓기", + "level": 3, + "parent_code": "FP-13-06", + "sort_order": 113920, + "tables": [ + { + "pum_table_id": "F0419", + "section": "13-6-1. 메쌓기", + "source_line": 7288, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "굴 삭 기 (무한궤도)" + ], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "직경 40㎝이상 ∼60㎝미만", + "작업반장", + "특별인부", + "보통인부", + "굴 삭 기 (무한궤도)", + "제잡비 비율" + ], + "condition_note": [ + "명칭", + "규격", + "단위", + "수량" + ], + "raw_row": [ + [ + "직경 40㎝이상 ∼60㎝미만", + "직경 60㎝이상 ∼80㎝미만", + "직경 80㎝이상 ∼100㎝이하", + "", + "", + "" + ], + [ + "작업반장", + "", + "인", + "0.83", + "0.75", + "0.68" + ], + [ + "특별인부", + "", + "〃", + "1.04", + "1.08", + "1.11" + ], + [ + "보통인부", + "", + "〃", + "1.04(1.17)", + "1.08(1.22)", + "1.11(1.25)" + ], + [ + "굴 삭 기 (무한궤도)", + "0.8㎥", + "h", + "3.84", + "3.52", + "3.14" + ], + [ + "제잡비 비율", + "", + "%", + "1(1)", + "1(1)", + "1(1)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-06-02", + "number": "13-6-2", + "name": "찰쌓기", + "level": 3, + "parent_code": "FP-13-06", + "sort_order": 114176, + "tables": [ + { + "pum_table_id": "F0420", + "section": "13-6-2. 찰쌓기", + "source_line": 7312, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "직경 40㎝이상 ∼60㎝미만", + "작업반장", + "특별인부", + "보통인부", + "굴착기 (무한궤도)", + "제잡비 비율" + ], + "condition_note": [ + "명칭", + "규격", + "단위", + "수량" + ], + "raw_row": [ + [ + "직경 40㎝이상 ∼60㎝미만", + "직경 60㎝이상 ∼80㎝미만", + "직경 80㎝이상 ∼100㎝이하", + "", + "", + "" + ], + [ + "작업반장", + "", + "인", + "0.83", + "0.75", + "0.68" + ], + [ + "특별인부", + "", + "〃", + "1.30", + "1.35", + "1.39" + ], + [ + "보통인부", + "", + "〃", + "1.30(1.47)", + "1.35(1.52)", + "1.39(1.56)" + ], + [ + "굴착기 (무한궤도)", + "0.8㎥", + "h", + "4.80", + "4.40", + "3.92" + ], + [ + "제잡비 비율", + "", + "%", + "9(9) 3(3)", + "9(9) 3(3)", + "9(9) 3(3)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-06-03", + "number": "13-6-3", + "name": "친환경 큰돌쌓기", + "level": 3, + "parent_code": "FP-13-06", + "sort_order": 114432, + "tables": [ + { + "pum_table_id": "F0421", + "section": "13-6-3. 친환경 큰돌쌓기", + "source_line": 7339, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "직경 40㎝이상 ~60㎝미만", + "작업반장", + "특별인부", + "보통인부", + "굴착기 (무한궤도)", + "제잡비 비율" + ], + "condition_note": [ + "명칭", + "규격", + "단위", + "수량" + ], + "raw_row": [ + [ + "직경 40㎝이상 ~60㎝미만", + "직경 60㎝이상 ~80㎝미만", + "직경 80㎝이상 ~100㎝이하", + "", + "", + "" + ], + [ + "작업반장", + "", + "인", + "1.19", + "1.07", + "0.97" + ], + [ + "특별인부", + "", + "〃", + "1.86", + "1.93", + "1.99" + ], + [ + "보통인부", + "", + "〃", + "1.86(2.10)", + "1.93(2.17)", + "1.99(2.23)" + ], + [ + "굴착기 (무한궤도)", + "0.8㎥", + "시간", + "6.86", + "6.29", + "5.60" + ], + [ + "제잡비 비율", + "", + "%", + "9(9) 3(3)", + "9(9) 3(3)", + "9(9) 3(3)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-07", + "number": "13-7", + "name": "큰돌붙이기", + "level": 2, + "parent_code": "FP-13", + "sort_order": 114688, + "tables": [] + }, + { + "work_item_code": "FP-13-07-01", + "number": "13-7-1", + "name": "메붙이기", + "level": 3, + "parent_code": "FP-13-07", + "sort_order": 114944, + "tables": [ + { + "pum_table_id": "F0422", + "section": "13-7-1. 메붙이기", + "source_line": 7367, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "직경 40㎝이상 ~60㎝미만", + "작업반장", + "특별인부", + "보통인부", + "굴착기 (무한궤도)", + "제 잡비 비율" + ], + "condition_note": [ + "명칭", + "규격", + "단위", + "수량" + ], + "raw_row": [ + [ + "직경 40㎝이상 ~60㎝미만", + "직경 60㎝이상 ~80㎝미만", + "직경 80㎝이상 ~100㎝이하", + "", + "", + "" + ], + [ + "작업반장", + "", + "인", + "0.58", + "0.53", + "0.48" + ], + [ + "특별인부", + "", + "〃", + "0.58", + "0.53", + "0.48" + ], + [ + "보통인부", + "", + "〃", + "0.83(1.01)", + "0.89(1.07)", + "0.94(1.11)" + ], + [ + "굴착기 (무한궤도)", + "0.8㎥", + "h", + "2.40", + "2.16", + "1.92" + ], + [ + "제 잡비 비율", + "", + "%", + "1(1)", + "1(1)", + "1(1)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-07-02", + "number": "13-7-2", + "name": "찰붙이기", + "level": 3, + "parent_code": "FP-13-07", + "sort_order": 115200, + "tables": [ + { + "pum_table_id": "F0423", + "section": "13-7-2. 찰붙이기", + "source_line": 7390, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "굴 삭 기 (무한궤도)" + ], + "formula_rows": [], + "special_glyphs": [ + "∼(U+223C)" + ], + "capacity_formula_here": false, + "variant_key": [ + "직경 40㎝이상 ∼60㎝미만", + "작업반장", + "특별인부", + "보통인부", + "굴 삭 기 (무한궤도)", + "제잡비 비율" + ], + "condition_note": [ + "명칭", + "규격", + "단위", + "수량" + ], + "raw_row": [ + [ + "직경 40㎝이상 ∼60㎝미만", + "직경 60㎝이상 ∼80㎝미만", + "직경 80㎝이상 ∼100㎝이하", + "", + "", + "" + ], + [ + "작업반장", + "", + "인", + "0.58", + "0.53", + "0.48" + ], + [ + "특별인부", + "", + "〃", + "1.01", + "1.02", + "1.02" + ], + [ + "보통인부", + "", + "〃", + "1.01(1.18)", + "1.02(1.19)", + "1.02(1.19)" + ], + [ + "굴 삭 기 (무한궤도)", + "0.8㎥", + "h", + "3.36", + "3.04", + "2.80" + ], + [ + "제잡비 비율", + "", + "%", + "11(11) 3(3)", + "11(11) 3(3)", + "12(11) 3(3)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-08", + "number": "13-8", + "name": "막돌쌓기", + "level": 2, + "parent_code": "FP-13", + "sort_order": 115456, + "tables": [ + { + "pum_table_id": "F0424", + "section": "13-8. 막돌쌓기", + "source_line": 7415, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "뒷길이 평균 적용" + ], + "condition_note": [ + "구 분", + "명 칭", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "뒷길이 평균 적용", + "보통인부", + "인", + "0.3", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-09", + "number": "13-9", + "name": "서식지 조성 돌쌓기 및 놓기", + "level": 2, + "parent_code": "FP-13", + "sort_order": 115712, + "tables": [ + { + "pum_table_id": "F0425", + "section": "13-9. 서식지 조성 돌쌓기 및 놓기", + "source_line": 7429, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "ton", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "인 력", + "석 공", + "장 비" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "인 력", + "석 공", + "장 비" + ], + "condition_note": [ + "구 분", + "규 격", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "인 력", + "특 별 인 부", + "", + "인", + "0.84" + ], + [ + "석 공", + "", + "인", + "2.51", + "" + ], + [ + "장 비", + "굴 삭 기", + "0.6㎥", + "시간", + "5.88" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-10", + "number": "13-10", + "name": "말뚝박기공", + "level": 2, + "parent_code": "FP-13", + "sort_order": 115968, + "tables": [] + }, + { + "work_item_code": "FP-13-10-01", + "number": "13-10-1", + "name": "말뚝다듬기", + "level": 3, + "parent_code": "FP-13-10", + "sort_order": 116224, + "tables": [ + { + "pum_table_id": "F0426", + "section": "13-10-1. 말뚝 다듬기", + "source_line": 7449, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "개", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "직경 15㎝ 길이 4m" + ], + "condition_note": [ + "구 분", + "보통인부(인)", + "형틀목공(인)", + "비 고" + ], + "raw_row": [ + [ + "직경 15㎝ 길이 4m", + "0.09", + "0.22", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-10-02", + "number": "13-10-2", + "name": "나무 말뚝박기", + "level": 3, + "parent_code": "FP-13-10", + "sort_order": 116480, + "tables": [ + { + "pum_table_id": "F0427", + "section": "13-10-2. 나무 말뚝박기", + "source_line": 7460, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통 인부 (인)" + ], + "condition_note": [ + "직종", + "말구 길이", + "6㎝", + "7.5㎝", + "9㎝", + "10.5㎝", + "비고" + ], + "raw_row": [ + [ + "보통 인부 (인)", + "0.9m 1.2m 1.5m 1.8m 2.1m 2.4m 2.7m 3.0m 3.5m 4.0m 4.5m", + "0.022 0.034 0.05 0.07 - - - - - - -", + "0.025 0.04 0.06 0.08 0.11 0.14 - - - - -", + "0.03 0.045 0.07 0.10 0.13 0.17 0.23 0.31 0.42 - -", + "0.035 0.05 0.08 0.12 0.16 0.22 0.28 0.38 0.54 0.77 1.08", + "" + ] + ] + }, + { + "pum_table_id": "F0428", + "section": "13-10-2. 나무 말뚝박기", + "source_line": 7471, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "개", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보 통 인 부 (인)" + ], + "condition_note": [ + "직종", + "말구 길이", + "12㎝", + "15㎝", + "18㎝", + "21㎝", + "24㎝", + "27㎝", + "30㎝", + "비고" + ], + "raw_row": [ + [ + "", + "1.5m 1.8m 2.1m 2.4m 2.7m 3.0m", + "0.18 0.21 0.24 0.31 0.39 0.51", + "0.22 0.27 0.32 0.41 0.51 0.70", + "- 0.35 0.41 0.51 0.65 0.90", + "- - - 0.64 0.80 1.15", + "- - - - - -", + "- - - - - -", + "- - - - - -", + "" + ], + [ + "보 통 인 부 (인)", + "3.5m 4.0m 4.5m 5.0m 5.5m 6.0m 6.5m 7.0m 7.5m 8.0m 8.5m 9.0m 10.0m 11.0m 12.0m", + "0.75 1.10 1.50 1.93 - - - - - - - - - - -", + "1.05 1.60 2.23 2.87 3.56 4.50 5.10 6.00 - - - - - - -", + "1.40 2.15 2.94 3.80 4.60 5.40 6.15 7.20 8.00 9.00 10.20 - - - -", + "1.80 2.65 3.60 4.60 5.35 6.30 7.20 8.40 9.35 10.50 11.80 13.20 14.70 - -", + "2.25 3.10 4.20 5.30 6.30 7.20 8.30 9.70 10.90 12.20 13.50 15.00 16.80 18.80 20.09", + "- - - - 7.00 8.20 9.60 11.50 12.80 14.00 15.20 17.00 19.20 21.70 24.40", + "- - - - 8.61 10.09 11.81 14.15 15.74 17.22 18.70 20.91 23.62 26.69 30.01", + "" + ] + ] + }, + { + "pum_table_id": "F0429", + "section": "13-10-2. 나무 말뚝박기", + "source_line": 7483, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "계수" + ], + "condition_note": [ + "관입률 =", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "0.8", + "비고" + ], + "raw_row": [ + [ + "계수", + "0.2", + "0.3", + "0.4", + "0.5", + "0.6", + "0.7", + "" + ] + ] + }, + { + "pum_table_id": "F0430", + "section": "13-10-2. 나무 말뚝박기", + "source_line": 7491, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "8 9", + "12", + "15", + "18", + "21", + "24", + "27" + ], + "condition_note": [ + "품종 말구 길이 (㎝) (m)", + "경유 (ℓ)", + "잡재료 (%)", + "기계 운전원 발동기 (인)", + "비계공 (인)", + "보통 인부 (인)", + "특별 인부 (인)", + "일기 당개 박수 (개)", + "발동기 (HP)", + "떨공이 (kg)", + "말뚝 중량 (kg/개)", + "윈치 (형식)" + ], + "raw_row": [ + [ + "8 9", + "3 4", + "5.0 5.0", + "11 11", + "1 1", + "2 2", + "3.6 3.6", + "1 1", + "24 20", + "5 5", + "50~70 50~70", + "20 26", + "단동 (單胴)" + ], + [ + "12", + "3 4 5", + "5.5 5.5 6.0", + "11 11 11", + "1 1 1", + "2 2 2", + "3.7 3.7 3.9", + "1 1 1", + "18 15 13", + "5 5 5", + "70~100 100~150 100~150", + "35 46 58", + "" + ], + [ + "15", + "4 5 6", + "6.0 6.5 6.5", + "11 11 11", + "1 1 1", + "2 2 2", + "3.8 3.9 3.9", + "1 1 1", + "12 11 9", + "5 5 5", + "150~200 200~300 200~300", + "72 90 108", + "" + ], + [ + "18", + "4 5 6 8 10", + "6.5 9.0 9.0 12.0 18.0", + "11 11 11 11 11", + "1 1 1 1 1", + "2 2 2 3 3", + "3.9 3.9 4.0 4.2 4.3", + "1 1 1 1 1", + "11 10 8 7 6", + "5 8 8 10 15", + "200~300 250~400 350~500 500~750 750~1,000", + "104 130 173 256 350", + "" + ], + [ + "21", + "5 6 8 10", + "9.0 12.0 18.0 24.0", + "11 11 11 11", + "1 1 1 1", + "2 2 3 3", + "4.1 4.0 4.2 4.2", + "1 1 1 1", + "9 7 6 5", + "8 10 15 20", + "350~800 450~750 750~1,000 1,000~1,500", + "176 232 338 450", + "" + ], + [ + "24", + "5 6 8 10", + "9.0 12.0 18.0 24.0", + "11 11 11 11", + "1 1 1 1", + "2 2 3 3", + "4.1 4.0 4.1 4.1", + "1 1 1 1", + "8 6 5 4", + "8 10 15 20", + "550~770 600~1,000 900~1,300 1,100~1,700", + "230 300 430 580", + "복동 (複胴)" + ], + [ + "27", + "6 8 10", + "18.0 24.0 30.0", + "11 11 11", + "1 1 1", + "2 3 3", + "3.9 4.0 3.9", + "1 1 1", + "5 4 3", + "15 20 25", + "750~1,000 1,000~1,500 1,500~2,000", + "376 536 720", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-11", + "number": "13-11", + "name": "돌망태", + "level": 2, + "parent_code": "FP-13", + "sort_order": 116736, + "tables": [] + }, + { + "work_item_code": "FP-13-11-01", + "number": "13-11-1", + "name": "원형", + "level": 3, + "parent_code": "FP-13-11", + "sort_order": 116992, + "tables": [ + { + "pum_table_id": "F0431", + "section": "13-11-1. 원형", + "source_line": 7520, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조약돌량(㎥)", + "인력(인)", + "돌 채 움" + ], + "condition_note": [ + "지름(㎝) 공종", + "45", + "50", + "55", + "60" + ], + "raw_row": [ + [ + "조약돌량(㎥)", + "0.29", + "0.32", + "0.36", + "0.39", + "" + ], + [ + "인력(인)", + "조립설치", + "0.08", + "0.09", + "0.10", + "0.11" + ], + [ + "돌 채 움", + "0.17", + "0.19", + "0.22", + "0.24", + "" + ] + ] + }, + { + "pum_table_id": "F0432", + "section": "13-11-1. 원형", + "source_line": 7534, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조립 설치", + "보통인부", + "돌채움", + "굴착기(1.0㎥)" + ], + "condition_note": [ + "지름(㎝) 공종", + "단위", + "40", + "45", + "50", + "60", + "90", + "100", + "120" + ], + "raw_row": [ + [ + "조립 설치", + "특별인부", + "인", + "0.035", + "0.040", + "0.044", + "0.053", + "0.097", + "0.112", + "0.135" + ], + [ + "보통인부", + "인", + "0.015", + "0.017", + "0.018", + "0.022", + "0.041", + "0.047", + "0.056", + "" + ], + [ + "돌채움", + "석 공", + "인", + "0.037", + "0.042", + "0.047", + "0.059", + "0.088", + "0.100", + "0.120" + ], + [ + "굴착기(1.0㎥)", + "시간", + "0.026", + "0.030", + "0.033", + "0.040", + "0.059", + "0.066", + "0.079", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-11-02", + "number": "13-11-2", + "name": "타원형", + "level": 3, + "parent_code": "FP-13-11", + "sort_order": 117248, + "tables": [ + { + "pum_table_id": "F0433", + "section": "13-11-2. 타원형", + "source_line": 7551, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조약돌량(㎥)", + "인력(인)", + "돌 채 움" + ], + "condition_note": [ + "지름(㎝) 공종", + "40", + "45", + "50", + "60", + "70", + "80", + "90", + "100" + ], + "raw_row": [ + [ + "조약돌량(㎥)", + "0.27", + "0.30", + "0.34", + "0.41", + "0.48", + "0.55", + "0.62", + "0.69", + "" + ], + [ + "인력(인)", + "조립설치", + "0.03", + "0.03", + "0.03", + "0.04", + "0.05", + "0.06", + "0.07", + "0.08" + ], + [ + "돌 채 움", + "0.16", + "0.18", + "0.20", + "0.25", + "0.29", + "0.33", + "0.37", + "0.42", + "" + ] + ] + }, + { + "pum_table_id": "F0434", + "section": "13-11-2. 타원형", + "source_line": 7565, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "조립 설치", + "보통인부", + "돌채움", + "굴착기 (1.0㎥)" + ], + "condition_note": [ + "지름(㎝) 공종", + "단위", + "40", + "45", + "50", + "60", + "70", + "80", + "90", + "100" + ], + "raw_row": [ + [ + "조립 설치", + "특별인부", + "인", + "0.013", + "0.014", + "0.016", + "0.019", + "0.024", + "0.030", + "0.035", + "0.040" + ], + [ + "보통인부", + "인", + "0.005", + "0.006", + "0.007", + "0.008", + "0.010", + "0.012", + "0.014", + "0.017", + "" + ], + [ + "돌채움", + "석 공", + "인", + "0.039", + "0.044", + "0.049", + "0.063", + "0.073", + "0.082", + "0.092", + "0.106" + ], + [ + "굴착기 (1.0㎥)", + "시간", + "0.026", + "0.030", + "0.033", + "0.040", + "0.046", + "0.053", + "0.059", + "0.066", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-11-03", + "number": "13-11-3", + "name": "매트리스형", + "level": 3, + "parent_code": "FP-13-11", + "sort_order": 117504, + "tables": [ + { + "pum_table_id": "F0435", + "section": "13-11-3. 매트리스형", + "source_line": 7581, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "특별인부", + "보통인부" + ], + "condition_note": [ + "구 분", + "단 위", + "조립설치", + "돌채움" + ], + "raw_row": [ + [ + "특별인부", + "인", + "0.004", + "0.013" + ], + [ + "보통인부", + "인", + "0.007", + "0.064" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-11-04", + "number": "13-11-4", + "name": "사각형", + "level": 3, + "parent_code": "FP-13-11", + "sort_order": 117760, + "tables": [ + { + "pum_table_id": "F0436", + "section": "13-11-4. 사각형", + "source_line": 7596, + "pum_form": "requirement", + "form_basis": "'수 량'", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "자재", + "채움재", + "잡재료", + "인력", + "특별인부", + "보통인부", + "장비" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "자재", + "철망태", + "㎥", + "1.03", + "" + ], + [ + "채움재", + "㎥", + "1.05", + "", + "" + ], + [ + "잡재료", + "%", + "3", + "철망태비의", + "" + ], + [ + "인력", + "석 공", + "인", + "0.072", + "" + ], + [ + "특별인부", + "인", + "0.053", + "", + "" + ], + [ + "보통인부", + "인", + "0.013", + "", + "" + ], + [ + "장비", + "유압식백호우 (무한궤도,0.7㎥)", + "hr", + "0.101", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-12", + "number": "13-12", + "name": "비탈다듬기", + "level": 2, + "parent_code": "FP-13", + "sort_order": 118016, + "tables": [] + }, + { + "work_item_code": "FP-13-12-01", + "number": "13-12-1", + "name": "뭉기기", + "level": 3, + "parent_code": "FP-13-12", + "sort_order": 118272, + "tables": [ + { + "pum_table_id": "F0437", + "section": "13-12-1. 뭉기기", + "source_line": 7612, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통토사", + "절 취(㎥)", + "투 입(㎥)", + "면고르기(시간)" + ], + "condition_note": [ + "구 분 공정별", + "보통인부(인)", + "비고" + ], + "raw_row": [ + [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사", + "", + "" + ], + [ + "절 취(㎥)", + "0.16", + "0.22", + "0.39", + "" + ], + [ + "투 입(㎥)", + "0.05", + "0.06", + "0.07", + "" + ], + [ + "면고르기(시간)", + "0.005(0.015)", + "0.009(0.021)", + "0.01(0.024)", + "㎡당" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-12-02", + "number": "13-12-2", + "name": "지오셀(사면보강)", + "level": 3, + "parent_code": "FP-13-12", + "sort_order": 118528, + "tables": [ + { + "pum_table_id": "F0438", + "section": "13-12-2. 지오셀(사면보강)", + "source_line": 7625, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "작 업 반 장", + "특 별 인 부", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "작 업 반 장", + "0.0141", + "" + ], + [ + "특 별 인 부", + "0.0381", + "" + ], + [ + "보 통 인 부", + "0.0099", + "" + ] + ] + }, + { + "pum_table_id": "F0439", + "section": "13-12-2. 지오셀(사면보강)", + "source_line": 7640, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "4m 표준", + "4 ~ 10m", + "10m 이상" + ], + "condition_note": [ + "구 분 공정별", + "1:2 표준", + "1:2 ~ 1:1.5", + "1:1.5 ~ 1:1", + "1:1 이상" + ], + "raw_row": [ + [ + "4m 표준", + "1", + "10", + "15", + "30" + ], + [ + "4 ~ 10m", + "10", + "20", + "25", + "40" + ], + [ + "10m 이상", + "20", + "30", + "35", + "50" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-13", + "number": "13-13", + "name": "흙막이", + "level": 2, + "parent_code": "FP-13", + "sort_order": 118784, + "tables": [] + }, + { + "work_item_code": "FP-13-13-01", + "number": "13-13-1", + "name": "목재틀흙막이", + "level": 3, + "parent_code": "FP-13-13", + "sort_order": 119040, + "tables": [ + { + "pum_table_id": "F0440", + "section": "13-13-1. 목재틀흙막이", + "source_line": 7652, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보통구조", + "중", + "상", + "중등구조", + "상등구조" + ], + "condition_note": [ + "구 분", + "건축목공", + "보통인부", + "비 고" + ], + "raw_row": [ + [ + "보통구조", + "하", + "6.285", + "0.682", + "" + ], + [ + "중", + "7.274", + "0.786", + "", + "" + ], + [ + "상", + "8.760", + "0.958", + "", + "" + ], + [ + "중등구조", + "보통", + "10.612", + "1.156", + "" + ], + [ + "상", + "13.767", + "1.497", + "", + "" + ], + [ + "상등구조", + "16.975", + "1.848", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-13-02", + "number": "13-13-2", + "name": "산림복원용 흙막이", + "level": 3, + "parent_code": "FP-13-13", + "sort_order": 119296, + "tables": [ + { + "pum_table_id": "F0441", + "section": "13-13-2. 산림복원용 흙막이", + "source_line": 7680, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": { + "인력": 10.0, + "장비": 90.0 + }, + "partial_ratio": true, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [ + "k", + "f", + "E", + "㎝(sec)" + ], + "special_glyphs": [], + "capacity_formula_here": true, + "variant_key": [ + "인력(10%)", + "장비(90%)", + "f", + "E", + "㎝(sec)" + ], + "condition_note": [ + "구 분", + "적 용", + "비 고" + ], + "raw_row": [ + [ + "인력(10%)", + "보통인부(인)", + "0.20", + "" + ], + [ + "장비(90%)", + "굴착기 (0.2㎥)", + "k", + "1.1" + ], + [ + "f", + "1", + "", + "" + ], + [ + "E", + "0.80", + "", + "" + ], + [ + "㎝(sec)", + "18(90°)", + "", + "" + ] + ] + }, + { + "pum_table_id": "F0442", + "section": "13-13-2. 산림복원용 흙막이", + "source_line": 7691, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "특 별 인 부", + "보 통 인 부", + "굴 삭 기" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "식생매트설치", + "특 별 인 부", + "보 통 인 부", + "굴 삭 기" + ], + "condition_note": [ + "구 분", + "규 격", + "단 위", + "수 량" + ], + "raw_row": [ + [ + "식생매트설치", + "복 토", + "", + "", + "" + ], + [ + "특 별 인 부", + "", + "인", + "0.014", + "-" + ], + [ + "보 통 인 부", + "", + "인", + "0.003", + "0.005" + ], + [ + "굴 삭 기", + "0.6 ㎥", + "시간", + "-", + "0.031" + ] + ] + }, + { + "pum_table_id": "F0443", + "section": "13-13-2. 산림복원용 흙막이", + "source_line": 7701, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎥", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "간 단 구 조", + "보 통 구 조" + ], + "condition_note": [ + "구 분", + "건축목공", + "보통인부", + "비 고" + ], + "raw_row": [ + [ + "간 단 구 조", + "2.80", + "0.80", + "" + ], + [ + "보 통 구 조", + "6.31", + "1.31", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-14", + "number": "13-14", + "name": "식생토낭 및 포트", + "level": 2, + "parent_code": "FP-13", + "sort_order": 119552, + "tables": [ + { + "pum_table_id": "F0444", + "section": "13-14. 식생토낭 및 포트", + "source_line": 7715, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "단 끊 기", + "흙채우기(마대채우기)", + "마대쌓기" + ], + "condition_note": [ + "구 분", + "보통인부(인)", + "비 고" + ], + "raw_row": [ + [ + "단 끊 기", + "0.030", + "단폭 30㎝적용" + ], + [ + "흙채우기(마대채우기)", + "0.035", + "" + ], + [ + "마대쌓기", + "0.025", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-15", + "number": "13-15", + "name": "목재공", + "level": 2, + "parent_code": "FP-13", + "sort_order": 119808, + "tables": [] + }, + { + "work_item_code": "FP-13-15-01", + "number": "13-15-1", + "name": "목재 껍질벗기기", + "level": 3, + "parent_code": "FP-13-15", + "sort_order": 120064, + "tables": [ + { + "pum_table_id": "F0445", + "section": "13-15-1. 목재 껍질벗기기", + "source_line": 7732, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "m", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "보 통 인 부" + ], + "condition_note": [ + "말구 직종", + "5cm", + "6cm", + "9cm", + "12cm", + "15cm", + "18cm", + "21cm", + "24cm", + "27cm", + "30cm" + ], + "raw_row": [ + [ + "보 통 인 부", + "0.0027", + "0.0033", + "0.005", + "0.007", + "0.008", + "0.01", + "0.012", + "0.014", + "0.015", + "0.017" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-15-02", + "number": "13-15-2", + "name": "목책 설치", + "level": 3, + "parent_code": "FP-13-15", + "sort_order": 120320, + "tables": [ + { + "pum_table_id": "F0446", + "section": "13-15-2. 목책 설치", + "source_line": 7738, + "pum_form": "undetermined", + "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [ + "~(U+FF5E)" + ], + "capacity_formula_here": false, + "variant_key": [ + "어려운 조건", + "쉬운 조건" + ], + "condition_note": [ + "작업조건", + "벌도목 직경", + "가지량", + "경사도" + ], + "raw_row": [ + [ + "어려운 조건", + "26 ~ 30cm", + "많음", + "15°이상" + ], + [ + "쉬운 조건", + "20 ~ 24cm", + "중간 이하", + "15°미만" + ] + ] + }, + { + "pum_table_id": "F0447", + "section": "13-15-2. 목책 설치", + "source_line": 7745, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "어려운 조건", + "쉬운 조건" + ], + "condition_note": [ + "작업조건", + "소요인력", + "적용인부" + ], + "raw_row": [ + [ + "어려운 조건", + "0.14", + "4인 1조(벌목부 1인, 보통인부 3인)" + ], + [ + "쉬운 조건", + "0.07", + "3인 1조(벌목부 1인, 보통인부 2인)" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-15-03", + "number": "13-15-3", + "name": "목재 가공 및 설치", + "level": 3, + "parent_code": "FP-13-15", + "sort_order": 120576, + "tables": [] + }, + { + "work_item_code": "FP-13-16", + "number": "13-16", + "name": "매트부설", + "level": 2, + "parent_code": "FP-13", + "sort_order": 120832, + "tables": [] + }, + { + "work_item_code": "FP-13-16-01", + "number": "13-16-1", + "name": "필터매트", + "level": 3, + "parent_code": "FP-13-16", + "sort_order": 121088, + "tables": [ + { + "pum_table_id": "F0448", + "section": "13-16-1. 필터매트", + "source_line": 7766, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 1.0, + "basis_unit": "㎡", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [ + "굴 삭 기" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "사 면", + "도로/철도", + "특별인부", + "보통인부", + "잠 수 조", + "굴 삭 기" + ], + "condition_note": [ + "구 분", + "규 격", + "단 위", + "육 상", + "수 중" + ], + "raw_row": [ + [ + "사 면", + "연약지반", + "사 면", + "연약지반", + "", + "", + "", + "" + ], + [ + "도로/철도", + "매립지", + "", + "", + "", + "", + "", + "" + ], + [ + "특별인부", + "-", + "인", + "0.07", + "0.09", + "0.10", + "0.16", + "0.24" + ], + [ + "보통인부", + "인", + "0.04", + "0.05", + "0.05", + "0.12", + "0.12", + "" + ], + [ + "잠 수 조", + "0.4㎥", + "조", + "-", + "-", + "-", + "0.08", + "0.15" + ], + [ + "굴 삭 기", + "시간", + "0.10", + "0.15", + "0.19", + "-", + "-", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-13-16-02", + "number": "13-16-2", + "name": "통기성매트", + "level": 3, + "parent_code": "FP-13-16", + "sort_order": 121344, + "tables": [ + { + "pum_table_id": "F0449", + "section": "13-16-2. 통기성매트", + "source_line": 7788, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": null, + "basis_unit": null, + "basis_source": null, + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": true, + "spaced_names": [ + "조 경 공", + "보 통 인 부" + ], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "폭 1.5m 이하", + "조 경 공", + "보 통 인 부" + ], + "condition_note": [ + "구 분", + "단 위", + "수 량", + "시 공 량 (㎡)" + ], + "raw_row": [ + [ + "폭 1.5m 이하", + "폭 2.0m 이하", + "", + "", + "" + ], + [ + "조 경 공", + "인", + "2", + "90", + "130" + ], + [ + "보 통 인 부", + "인", + "1", + "", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-14", + "number": "14", + "name": "부대공", + "level": 1, + "parent_code": null, + "sort_order": 121600, + "tables": [] + }, + { + "work_item_code": "FP-14-01", + "number": "14-1", + "name": "입목뿌리 밑막이", + "level": 2, + "parent_code": "FP-14", + "sort_order": 121856, + "tables": [ + { + "pum_table_id": "F0450", + "section": "14-1. 입목뿌리 밑막이", + "source_line": 7802, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "0.8㎥ 굴착기(우드그랩 부착)", + "보통인부" + ], + "condition_note": [ + "장 비", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "0.8㎥ 굴착기(우드그랩 부착)", + "h", + "0.17", + "장비이동거리 50m이내 기준" + ], + [ + "보통인부", + "h", + "0.5", + "" + ] + ] + } + ] + }, + { + "work_item_code": "FP-14-02", + "number": "14-2", + "name": "근주이식", + "level": 2, + "parent_code": "FP-14", + "sort_order": 122112, + "tables": [ + { + "pum_table_id": "F0451", + "section": "14-2. 근주이식", + "source_line": 7813, + "pum_form": "requirement", + "form_basis": "직종 표기((인)·인부·공)", + "basis_quantity": 10.0, + "basis_unit": "본", + "basis_source": "본문", + "resource_shares": {}, + "partial_ratio": false, + "expression_cells": [], + "crew_table": false, + "spaced_names": [], + "formula_rows": [], + "special_glyphs": [], + "capacity_formula_here": false, + "variant_key": [ + "0.8㎥ 굴착기", + "보통인부" + ], + "condition_note": [ + "장 비", + "단 위", + "수 량", + "비 고" + ], + "raw_row": [ + [ + "0.8㎥ 굴착기", + "h", + "0.25", + "장비 이동거리 50m이내 기준" + ], + [ + "보통인부", + "h", + "0.66", + "" + ] + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/resources/knowledge/03_미결_및_확인사항.md b/resources/knowledge/03_미결_및_확인사항.md index 7e849b3d..592bc076 100644 --- a/resources/knowledge/03_미결_및_확인사항.md +++ b/resources/knowledge/03_미결_및_확인사항.md @@ -10,6 +10,9 @@ | 14 | [공통] 설계심사 체크리스트 — 실무 사례 대조 | **정리 완료(2026-08-13)**: 사방 = 고시 별표2 전 24항 표화([02_타당성평가 §4](technical_info/02_사방댐/01_대상지_타당성평가/02_타당성평가.md)), 임도 = 규정 제12조의2+별지18 9항 반영([설계제원_총괄 §10](technical_info/01_임도/02_상세설계/설계제원_총괄.md)). 잔여 = **실무 심의 사례와 대조** (사례 자료 미확보) | 심의 사례 자료 확보 시 대조 | 좌동 | 자료 대기 (2026-08-13) | | 15 | [공통] 사방댐 부위별 강도 실무값 (구 No.6 통합) | 잔여 = 부위별(본체/방수로 부배합/물받이) 강도 수치 관측 — **콘크리트 사방댐 실시설계 내역서** 필요 (기존 실무문서는 혼합쌓기라 데이터 없음). 부위 구분 4단위는 해소됨([콘크리트_재료 §1-1](technical_info/01_임도/02_상세설계/구조물/콘크리트_재료.md)). 선정=개발 단계 | 내역서 사용자 제공 대기 | [02_사방댐/05_원가정보/01_원가_특이사항](technical_info/02_사방댐/05_원가정보/01_원가_특이사항.md) §3 | 자료 대기 (2026-08-13) | | 19 | [공통] ★STmate 실기 환경 확보 (STC 입출력 검증 전제) | STC = 발주처 제출 최종물, Aislo import/export 필수 요구사항(사용자 확정 2026-08-15). 포맷 명세는 [original/원가계산/STmate/](original/원가계산/STmate/) 정리 완료. **잔여 = STmate 실행 환경** — 생성·변형 STC를 열기→조회→수정→재계산→저장→재개방까지 검증해야 빈 테이블(BDQTY·SYSINFO) export 허용 여부·신규생성 규칙 확정 가능. 환경 없으면 [STC_왕복검증](original/원가계산/STmate/STC_왕복검증.md) R1~R9 실행 불가 | 선택지: ① STmate 보유·구매 ② 체험판 ③ 설계사무소 협조. **개발 단계 사용자 결정** | [STmate 포맷 명세](original/원가계산/STmate/_meta.md), [STC_난독화_상태](original/원가계산/STmate/STC_난독화_상태.md) | 미결 — 사용자 결정 대기 (2026-08-15) | +| 20 | [임도] 유토곡선 평형선·극값 임계·balloon 표기 기준 | 법령·행정규칙·표준시방서·교본에 **세부 규칙 없음**(2026-09-03 확인). KDS 44 30 00 2.3.3은 「구간별 균형 배분·운반거리 최소화」까지만 규정. 실무 도면(`유토곡선.dwg`)은 압축 DWG라 표기 문자열 추출 불가 — 표기 관행은 도면 육안 확인 필요. 실무 곡선 원본 6건 관측치는 문서에 정리 | 실무 유토곡선 도면 육안 확인 후 사용자 협의로 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §2·§3 | 미결 — 근거 공백 (2026-09-03) | +| 21 | [임도] 토공 운반장비 한계거리 — 도자 60m vs 70m | 실무 `EARTH.DAT` 헤더 6건 전부 `무대 20.0 / 도자 60.0`. Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`은 20/70. 현행 법령·품셈에 장비별 한계거리 규정 없음 — 두 값 모두 후보 | 개발 단계 사용자 협의로 채택값 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §4 | 미결 — 사용자 결정 대기 (2026-09-03) | +| 22 | [임도] 옆도랑(측구) 사다리꼴 단면 — 저폭 근거 공백 | 별표2 근거는 **너비 0.5~1 m · 깊이 30 ㎝ 내외**까지뿐([측구 §1](technical_info/01_임도/02_상세설계/측구.md))이고 **저폭 수치는 원문 전수에 없음**. Aislo 현행 0.9/0.3/0.3(상단폭·저폭·깊이)은 표준횡단도면 판독값이며 측벽이 45°라 [통수단면 §4](technical_info/01_임도/03_계산정보/통수단면.md) 경제 단면(사다리형 측벽 **60°**, B≒1.155H)과 어긋남 — 두 축 중 무엇을 기본으로 둘지 미정 **실물 근거 1건(2026-09-08)**: 소광 기번8 내역서 「제형돌수로 **B=0.75 m**」 — 별표2 너비 범위 안. ⚠ 한 공사지 한 줄이라 일반화 불가 | 개발 단계 사용자 협의로 채택 축 확정 (도면 판독값 vs 경제 단면식) | [측구](technical_info/01_임도/02_상세설계/측구.md) §1·[통수단면](technical_info/01_임도/03_계산정보/통수단면.md) §4 | 미결 — 사용자 결정 대기 (2026-09-08) | ## §2 확인사항 — 임도교본(2019) vs 사방교본(2023) 충돌 리스트 (2026-08-12, 최종검증에서 도출) 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/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p35_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p35_1.png new file mode 100644 index 00000000..9d775dad Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p35_1.png differ diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p39_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p39_1.png new file mode 100644 index 00000000..af34ae6d Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p39_1.png differ diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png new file mode 100644 index 00000000..73c309b9 Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png differ diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png new file mode 100644 index 00000000..e88ed606 Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png differ diff --git a/resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md b/resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md new file mode 100644 index 00000000..1c366e5e --- /dev/null +++ b/resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md @@ -0,0 +1,94 @@ +--- +title: 유토곡선(토적도) 토량배분 — 평형선·장비 한계거리·표기 +category: 03_계산정보 +sources: + - doc: KDS 44 30 00 도로토공 (토량 배분·토적도) + version: 23.01 (현행) + loc: original/표준시방서/도로공사 표준시방서 (KCS 44 00 00)/첨부/[압축] 도로설계기준(KDS 44 00 00)/KDS 44 30 00 도로토공(23.01).md + - doc: 산림사업 표준품셈 (산림청고시 제2025-82호) + version: 2026.01.01 시행 + loc: original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md + - doc: 실무 설계 산출물 유토곡선 원본 (.MASS · .med · EARTH.DAT) 6개 노선 + version: 2024~2025년 설계분 + loc: original/실무문서/**/유토곡선.MASS +status: draft +last_updated: 2026-09-03 +--- + +관련 문서: [토량계산](토량계산.md) · [횡단면적](횡단면적.md) · [토공_수량](../04_수량분석정보/토공_수량.md) · [종단선형](../02_상세설계/종단선형.md) + +## 1. 현행 근거로 정해지는 것 + +| 항목 | 근거 | 내용 | +|---|---|---| +| 토량 배분 원칙 | KDS 44 30 00(23.01) 2.3.3 | 노선 전체 + **구간별 균형 배분**, **운반거리 최소화**. 토적도(Mass Curve)는 종방향 이동만 표시 | +| 절토 구분 | 같은 조 | 토사·리핑암·발파암으로 구분 산출 | +| 곡선의 기준 상태 | 품셈 1-2-3의 3 | 절토(자연) × C = 다짐 성토량. 곡선은 다짐상태 기준 | +| 시공계획고 | [종단선형 §4](../02_상세설계/종단선형.md) | 절·성토 균형 | + +## 2. 현행 근거가 없는 것 (공백 — 확정은 사용자 협의) + +법령·행정규칙·표준시방서·임도기술교본 어디에도 다음 세부 규칙은 없다. 실무 설계 프로그램이 +자체 로직으로 처리하는 영역이다. + +- **평형선(Balance Line) 자동 선정 규칙** — 어느 높이에 몇 개를 그을지. +- **극값 무시 임계** — 잔 진동을 블록으로 볼지 버릴지의 경계값. +- **사토·토취 물량 표기(balloon) 관행** — 표기 단위·위치·기재 항목. + +실무 도면(`유토곡선.dwg`) 은 압축 DWG 라 표기 문자열을 추출하지 못한다(2026-09-03 확인). +표기 관행은 도면 육안 확인이 필요하다. + +## 3. 실무 원본 관측 — 유토곡선 6개 노선 + +`유토곡선.MASS` = `측점, 누가거리(m), 누가토량(㎥)`. 같은 폴더 `.med` 는 같은 점 수에 좌표 +간격이 정확히 1/2 인 **작도용 축척 사본**이다. + +| 노선 | 연장(m) | 점 수 | 최종 누가토량 | 최소 | 최대 | 0선 교차 | 극값 수 | EARTH.DAT 한계거리 | +|---|---|---|---|---|---|---|---|---| +| 01.산불거창/거창북면본선 | 1,440 | 132 | **+156㎥** | −1,524 | 1,293 | 3 | 31 | 20.0/60.0 | +| 02.산불 장수/오솔길4차 | 2,000 | 171 | **+191㎥** | −916 | 1,872 | 9 | 44 | 20.0/60.0 | +| 03.산불진안/02.오솔길 | 1,900 | 161 | **+229㎥** | −824 | 2,663 | 15 | 31 | 20.0/60.0 | +| 04.산불 봉화/_오솔길(현동) | 3,465 | 329 | **+324㎥** | −2,544 | 2,116 | 17 | 63 | 20.0/60.0 | +| 05.동부청 영월/본선 | 2,680 | 224 | **+202㎥** | −2,836 | 3,736 | 7 | 39 | 20.0/60.0 | +| 05.동부청 영월/지선 | 520 | 38 | **+49㎥** | −359 | 1,788 | 5 | 12 | 20.0/60.0 | + +관측 사실: + +- **최종 누가토량이 전부 +49 ~ +324㎥** — 곡선 진폭(2,146~6,573㎥)의 1~7% 수준. 실무 설계는 + 노선 전체 절·성토를 균형에 맞춰 끝낸다(KDS 2.3.3 · 종단선형 §4와 일치). +- **0선을 3~17회 교차**하고 극값이 12~63개다 — 평형선·운반 블록이 노선 전체에 흩어진다. +- 측점 간격은 20m 고정이 아니라 2~20m 혼재다(지형 급변점 보조말뚝). + +## 4. 장비 한계거리 — 근거별 후보 + +| 출처 | 무대(종무대) | 도자(불도저) | 그 밖 | +|---|---|---|---| +| 실무 EARTH.DAT 헤더 6건 (전부 동일) | 20.0 m | **60.0 m** | 나머지 덤프 | +| Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` | 20.0 m | **70.0 m** | 나머지 덤프 | + +EARTH.DAT 첫 줄은 `C계수 1.0 1.0 1.0 무대한계 도자한계` 형식이며, C계수는 5건 0.9000 · +1건 0.9400 이다(품셈 L·C 표의 점질토·모래 계열 C 범위 안). + +현행 법령·품셈에는 **운반 장비별 한계거리 규정이 없다** — 위 두 값은 모두 후보이며 확정은 +사용자 협의 사항이다. (★ 근본 원칙 — 실무는 과거 데이터·참조용) + +## 5. 프로그램 대조 (2026-09-03 관측) + +용화 검증 프로젝트(연장 1,106m)의 유토곡선은 **최종 누가토량 −11,094㎥ · 0선 교차 0회**로, +위 실무 6건과 성격이 전혀 다르다(단조 하강). 그 결과 평형선·장비 띠·물량 balloon 이 생기지 +않는다 — 곡선이 0선을 넘지 않으면 블록이 하나로 뭉치기 때문이다. 절·성토 균형 판정과 조정은 +**사용자 몫**이며(2026-09-03 사용자 확정: 「설계프로그램은 알려만 주면 됨, 자동 조정 금지」), +프로그램은 불균형 수치를 화면에 드러내기만 한다. + +## 근거 + +| 항목 | 출처 | 위치 | +|---|---|---| +| 토량 배분·토적도 | KDS 44 30 00(23.01) 2.3.3 | 도로토공(23.01) md L130~142 | +| 절토 자연상태·L·C | 품셈 1-2-1 / 1-2-3의 3 | 품셈 md L541~572 / L703~735 | +| 실무 유토곡선 6건 | `original/실무문서/**/유토곡선.MASS` · `EARTH.DAT` | 2026-09-03 파싱 | + +## 미결 + +- 평형선 자동 선정·극값 무시 임계·balloon 표기 관행 → [03_미결_및_확인사항](../../../03_미결_및_확인사항.md) +- 장비 한계거리 도자 60m(실무) vs 70m(현행 구현) 채택 → 같은 문서 diff --git a/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md b/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md index 6ff44ed2..013e3837 100644 --- a/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md +++ b/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md @@ -15,7 +15,7 @@ status: draft last_updated: 2026-08-17 --- -관련 문서: [횡단면적](횡단면적.md) · [절토_비탈면](../02_상세설계/절토_비탈면.md) · [성토_비탈면](../02_상세설계/성토_비탈면.md) · [유용토운반작업장](../02_상세설계/유용토운반작업장.md) · [토사채취장](../02_상세설계/토사채취장.md) · [토공_수량](../04_수량분석정보/토공_수량.md) +관련 문서: [유토곡선_토량배분](유토곡선_토량배분.md) · [횡단면적](횡단면적.md) · [절토_비탈면](../02_상세설계/절토_비탈면.md) · [성토_비탈면](../02_상세설계/성토_비탈면.md) · [유용토운반작업장](../02_상세설계/유용토운반작업장.md) · [토사채취장](../02_상세설계/토사채취장.md) · [토공_수량](../04_수량분석정보/토공_수량.md) ## 1. 체적 계산 방법 diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index c3c7fe98..d0b83d57 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1,1296 +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": "{{설계자서명}}" - } - } - ], - "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/resources/template_2dDrawing/00_template_cover.json b/resources/template_2dDrawing/00_template_cover.json index 4dff3543..901b9426 100644 --- a/resources/template_2dDrawing/00_template_cover.json +++ b/resources/template_2dDrawing/00_template_cover.json @@ -332,6 +332,34 @@ "fontFamily": "sans-serif" } } + }, + { + "id": "944f4ed1-a4e0-5ddf-b91f-3847984d2659", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.표지", + "shapeData": { + "points": [ + { + "x": 57.4, + "y": 64.06 + }, + { + "x": 137.4, + "y": 64.06 + }, + { + "x": 137.4, + "y": 104.06 + }, + { + "x": 57.4, + "y": 104.06 + } + ], + "imageData": "{{회사로고}}" + } } ], "layers": [ @@ -348,4 +376,4 @@ "isLocked": false } ] -} \ No newline at end of file +} diff --git a/ui_template/ui_template_compass.css b/ui_template/ui_template_compass.css new file mode 100644 index 00000000..9cd28c73 --- /dev/null +++ b/ui_template/ui_template_compass.css @@ -0,0 +1,71 @@ +/* ============================================================================= + * ui_template_compass.css + * 3D 뷰어 방위 나침반(`ui_template_compass.ts`) 전용 스타일. + * + * B04 지표면 뷰어에만 있던 것을 B05 종단 3D 뷰어와 함께 쓰려고 공용으로 옮겼다 + * (2026-09-03). 색상 하드코딩 금지 원칙대로 전부 토큰을 쓴다. + * ========================================================================== */ + +/* 3D 뷰어 방위 나침반 — 모양·색만 여기서 정한다. + **놓을 자리는 페이지 CSS 몫**이다(B04 우하단, B05 는 ISO 버튼 아래). 위치를 여기 박으면 + 페이지마다 덮어써야 해서 규칙이 엉킨다. */ +.ui-compass { + position: absolute; + z-index: 2; + pointer-events: none; + filter: drop-shadow(0 1px 2px var(--color-surface-raised)); +} + +/* inline SVG의 baseline 여백을 없앤다 — 안 그러면 아래로 5px 떠 모서리 여백이 어긋난다. */ +.ui-compass svg { + display: block; +} + +/* 지면에 누운 나침반 링 — 좌표는 카메라 자세대로 매 프레임 다시 쓰인다. + 링 안쪽 면은 지형이 비치도록 아주 옅게만 깔아 도면 위 방위표처럼 읽힌다. */ +.ui-compass-face { + fill: var(--color-surface-raised); + fill-opacity: 0.5; + stroke: none; +} + +.ui-compass-ring { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1.4; +} + +.ui-compass-tick { + stroke: var(--color-border); + stroke-width: 1; +} + +.ui-compass-tick--major { + stroke: var(--color-text-secondary); + stroke-width: 1.6; +} + +/* 도북 화살 — 이 위젯에서 가장 먼저 읽혀야 하는 하나. */ +.ui-compass-arrow { + fill: var(--color-danger, #d64545); +} + +/* 표고축 — 내려다볼수록 짧아져 시점 기울기를 한 번 더 알린다. */ +.ui-compass-pole { + stroke: var(--color-text-body); + stroke-width: 1.6; + stroke-linecap: round; + opacity: 0.75; +} + +.ui-compass-label { + fill: var(--color-text-secondary); + font-family: var(--font-body); + font-size: 11px; + font-weight: var(--font-weight-semibold); + text-anchor: middle; +} + +.ui-compass-label--north { + fill: var(--color-danger, #d64545); +} diff --git a/ui_template/ui_template_compass.ts b/ui_template/ui_template_compass.ts new file mode 100644 index 00000000..9bd58b68 --- /dev/null +++ b/ui_template/ui_template_compass.ts @@ -0,0 +1,218 @@ +/* 3D 뷰어 방위 표시 — **지면에 누운 나침반 링**(2026-09-03 사용자 선택). + * + * B04 지표면 뷰어 전용이었으나 B05 종단 3D 뷰어도 같은 표시가 필요해져 공용으로 옮겼다 + * (2026-09-03). 스타일은 이 모듈이 직접 물고 오고(`ui_template_compass.css`), **놓을 자리만** + * 페이지가 클래스로 준다 — B04 는 우하단(축척 막대와 짝), B05 는 ISO 버튼 아래다. + * + * 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서 + * 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과 + * 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 링·눈금·도북 화살을 **지면 평면 + * 위의 도형으로 두고 카메라에 투영**하면 그 어긋남이 원리적으로 사라지고, 링이 눌린 + * 정도가 곧 시점의 기울기가 된다(TerriaJS·cesium-navigation 계열 표기). + * + * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x(동), 높이, -y. 그래서 세계에서 + * 북쪽은 **-z**, 동쪽은 **+x**, 표고는 **+y** 다. 카메라 오프셋(카메라 − 타깃)만 있으면 + * 화면 기저를 만들 수 있다(up = +y 고정): + * · 오른쪽 r = (oz, 0, −ox)/h, 위 u = (−ox·oy, h², −oz·oy)/(h·L) + * · 화면 좌표 = (v·r, v·u), 깊이 = v·(−offset)/L — 양수면 시선 방향(뒤)이라 흐리게. + * + * 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한). + */ + +import "./ui_template_compass.css"; + +export interface TerrainCompass { + root: HTMLElement; + /** 카메라 오프셋(카메라 위치 − 타깃)으로 링·화살·표고침을 맞춘다. */ + update(offsetX: number, offsetY: number, offsetZ: number): void; + setVisible(visible: boolean): void; +} + +/** 기본 크기(px). 호출부가 `sizePx` 로 덮을 수 있다 — B05 는 조금 크게 쓴다. */ +const DEFAULT_SIZE_PX = 84; +const VIEW_BOX = 104; +const CENTER = 0; +const RING_RADIUS = 34; +/** 눈금 간격(도)과 90°마다 주는 긴 눈금. */ +const TICK_STEP_DEG = 15; +const TICK_INNER = 0.93; +const TICK_INNER_MAJOR = 0.86; +const LABEL_RADIUS = RING_RADIUS * 1.3; +/** 링을 그리는 다각형 분할(도) — 6°면 84px에서 원으로 보인다. */ +const RING_STEP_DEG = 6; +/** 이보다 뒤로 넘어간 글자는 흐리게 — 0 근처에서 깜빡이지 않게 여유를 둔다. */ +const BEHIND_DEPTH = 0.3; +const DEG = Math.PI / 180; + +/** 방위(도, 북=0, 시계 방향) 위치의 지면 벡터. */ +function bearingVector(bearingDeg: number, radius: number): [number, number, number] { + const radians = bearingDeg * DEG; + return [Math.sin(radians) * radius, 0, -Math.cos(radians) * radius]; +} + +const CARDINALS: Array<[string, number]> = [ + ["N", 0], + ["E", 90], + ["S", 180], + ["W", 270], +]; + +function svg(tag: string, attributes: Record): SVGElement { + const element = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value); + return element; +} + +export interface TerrainCompassOptions { + /** 위젯 한 변의 크기(px). 기본 84. */ + sizePx?: number; + /** 놓을 자리를 정하는 페이지 클래스 — 위치 지정은 페이지 CSS 몫이다. */ + className?: string; +} + +export function createTerrainCompass(options: TerrainCompassOptions = {}): TerrainCompass { + const root = document.createElement("div"); + root.className = options.className ? `ui-compass ${options.className}` : "ui-compass"; + root.hidden = true; + root.title = "도북(N) — 링이 지면에 누워 시점 기울기를 함께 보여 준다"; + + const half = VIEW_BOX / 2; + const canvas = svg("svg", { + viewBox: `${-half} ${-half} ${VIEW_BOX} ${VIEW_BOX}`, + width: String(options.sizePx ?? DEFAULT_SIZE_PX), + height: String(options.sizePx ?? DEFAULT_SIZE_PX), + "aria-hidden": "true", + }); + const face = svg("path", { class: "ui-compass-face", d: "" }); + const ring = svg("path", { class: "ui-compass-ring", d: "" }); + const ticks: SVGElement[] = []; + for (let bearing = 0; bearing < 360; bearing += TICK_STEP_DEG) { + const tick = svg("line", { + class: `ui-compass-tick${bearing % 90 === 0 ? " ui-compass-tick--major" : ""}`, + x1: "0", + y1: "0", + x2: "0", + y2: "0", + }); + ticks.push(tick); + } + const arrow = svg("path", { class: "ui-compass-arrow", d: "" }); + const pole = svg("line", { + class: "ui-compass-pole", + x1: "0", + y1: "0", + x2: "0", + y2: "0", + }); + const labels = CARDINALS.map(([name]) => + svg("text", { + class: `ui-compass-label${name === "N" ? " ui-compass-label--north" : ""}`, + x: "0", + y: "0", + }), + ); + labels.forEach((label, index) => { + label.textContent = CARDINALS[index][0]; + }); + const poleLabel = svg("text", { class: "ui-compass-label", x: "0", y: "0" }); + poleLabel.textContent = "Z"; + + canvas.append(face, ring, ...ticks, arrow, pole, ...labels, poleLabel); + root.append(canvas); + + // 프레임마다 다시 그리지 않도록 직전 시선 방향(단위 벡터)을 들고 있는다. + let last = { x: Number.NaN, y: 0, z: 0 }; + + return { + root, + update(offsetX: number, offsetY: number, offsetZ: number): void { + const length = Math.hypot(offsetX, offsetY, offsetZ); + if (!(length > 0)) return; + const unit = { x: offsetX / length, y: offsetY / length, z: offsetZ / length }; + const moved = + !Number.isFinite(last.x) || + Math.abs(unit.x - last.x) + Math.abs(unit.y - last.y) + Math.abs(unit.z - last.z) > 0.008; + if (!moved) return; + last = unit; + // 수평 성분. 정확히 수직으로 내려다보면 0이 되므로 하한을 둔다(시점 프리셋도 2° 기울임). + const horizontal = Math.max(Math.hypot(offsetX, offsetZ), 1e-6); + + /** 세계 벡터 → 화면 좌표(SVG는 y가 아래로 자라 위 성분을 뒤집는다)와 깊이. */ + const project = ( + vector: [number, number, number], + ): { x: number; y: number; depth: number } => { + const [vx, vy, vz] = vector; + const alongCamera = vx * offsetX + vz * offsetZ; + // 깊이는 방향만 보므로 벡터 길이로 정규화한다(1 = 시선 정방향, −1 = 화면 앞). + const size = Math.max(Math.hypot(vx, vy, vz), 1e-6); + return { + x: CENTER + (vx * offsetZ - vz * offsetX) / horizontal, + y: + CENTER - (vy * horizontal * horizontal - alongCamera * offsetY) / (horizontal * length), + depth: -(vx * unit.x + vy * unit.y + vz * unit.z) / size, + }; + }; + + /** 지면 원(반지름 r)을 다각형으로 — 시점이 누우면 그대로 눌린 타원이 된다. */ + const groundCircle = (radius: number): string => { + let path = ""; + for (let bearing = 0; bearing <= 360; bearing += RING_STEP_DEG) { + const point = project(bearingVector(bearing, radius)); + path += `${path ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`; + } + return `${path}Z`; + }; + + ring.setAttribute("d", groundCircle(RING_RADIUS)); + face.setAttribute("d", groundCircle(RING_RADIUS * 0.97)); + + ticks.forEach((tick, index) => { + const bearing = index * TICK_STEP_DEG; + const inner = project( + bearingVector( + bearing, + RING_RADIUS * (bearing % 90 === 0 ? TICK_INNER_MAJOR : TICK_INNER), + ), + ); + const outer = project(bearingVector(bearing, RING_RADIUS)); + tick.setAttribute("x1", inner.x.toFixed(2)); + tick.setAttribute("y1", inner.y.toFixed(2)); + tick.setAttribute("x2", outer.x.toFixed(2)); + tick.setAttribute("y2", outer.y.toFixed(2)); + }); + + // 도북 화살도 지면에 누운 삼각형이라 시점이 눕는 만큼 함께 눌린다. + const tip = project(bearingVector(0, RING_RADIUS * 0.98)); + const left = project(bearingVector(-14, RING_RADIUS * 0.52)); + const right = project(bearingVector(14, RING_RADIUS * 0.52)); + arrow.setAttribute( + "d", + `M${tip.x.toFixed(2)} ${tip.y.toFixed(2)}L${left.x.toFixed(2)} ${left.y.toFixed(2)}` + + `L${right.x.toFixed(2)} ${right.y.toFixed(2)}Z`, + ); + + // 표고축 — 링 가운데에서 곧게 선 침. 내려다볼수록 짧아져 시점을 한 번 더 알린다. + const up = project([0, RING_RADIUS * 0.9, 0]); + pole.setAttribute("x1", String(CENTER)); + pole.setAttribute("y1", String(CENTER)); + pole.setAttribute("x2", up.x.toFixed(2)); + pole.setAttribute("y2", up.y.toFixed(2)); + + labels.forEach((label, index) => { + const point = project(bearingVector(CARDINALS[index][1], LABEL_RADIUS)); + label.setAttribute("x", point.x.toFixed(2)); + // 글자는 baseline이 아래라 시각 중심을 맞추려면 조금 내린다. + label.setAttribute("y", (point.y + 3.5).toFixed(2)); + label.setAttribute("opacity", point.depth > BEHIND_DEPTH ? "0.5" : "1"); + }); + const poleTip = project([0, LABEL_RADIUS * 0.78, 0]); + poleLabel.setAttribute("x", poleTip.x.toFixed(2)); + poleLabel.setAttribute("y", (poleTip.y + 3.5).toFixed(2)); + // 위에서 내려다보면 표고축이 점으로 눌려 링 가운데 글자만 남는다 — 그때는 감춘다. + poleLabel.setAttribute("opacity", Math.abs(poleTip.y - CENTER) < 8 ? "0" : "0.8"); + }, + setVisible(visible: boolean): void { + root.hidden = !visible; + }, + }; +} diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index d50e9d3b..9e74c56b 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -9,35 +9,16 @@ * - 스타일 규칙은 injectBaseStyles()로 1회 주입 (design.md 컴포넌트 명세 기반). * ========================================================================== */ -/* ----------------------------------------------------------------------------- - * 0. 내부 유틸 - * -------------------------------------------------------------------------- */ +import { el } from "./ui_template_elements_base"; + +// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다. +export * from "./ui_template_elements_chart"; +export * from "./ui_template_elements_styles"; +export { el } from "./ui_template_elements_base"; /** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ -function el( - tag: K, - options: { - className?: string; - text?: string; - attrs?: Record; - children?: (HTMLElement | string)[]; - } = {}, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (options.className) node.className = options.className; - if (options.text !== undefined) node.textContent = options.text; - if (options.attrs) { - for (const [k, v] of Object.entries(options.attrs)) { - node.setAttribute(k, v); - } - } - if (options.children) { - for (const child of options.children) { - node.append(child); - } - } - return node; -} + /* ----------------------------------------------------------------------------- * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav @@ -380,538 +361,3 @@ export function createWorkflowShell(opts: WorkflowShellOptions): WorkflowShellHa * 외부 라이브러리 없이 인라인 SVG. 색상은 CSS 클래스 + theme.css 변수 참조. * -------------------------------------------------------------------------- */ -export interface LineChartSeries { - /** 범례에 표시할 이름 (i18n 결과 문자열) */ - name: string; - /** y 값 배열 (x는 인덱스 순서, null은 결측으로 선 끊김) */ - values: (number | null)[]; - /** 선 색상 클래스 접미사: 0~3 (theme.css의 --color-chart-N 참조) */ - colorIndex?: 0 | 1 | 2 | 3; -} - -export interface LineChartOptions { - series: LineChartSeries[]; - /** x축 라벨 (values와 같은 길이 권장, 일부만 자동 선택 표기) */ - xLabels?: string[]; - /** y축 최대값 (기본: 100 = 퍼센트) */ - yMax?: number; - /** y축 단위 접미사 (기본: "%") */ - yUnit?: string; - /** 접근성 설명 */ - ariaLabel?: string; - /** 커스텀 가상 가로폭 (기본: CHART_W = 640) */ - width?: number; - /** 커스텀 가상 세로폭 (기본: CHART_H = 200) */ - height?: number; -} - -const CHART_W = 640; -const CHART_H = 200; -const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 }; -const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시) - -/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */ -function splinePath(points: { x: number; y: number }[]): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M${points[0].x},${points[0].y}`; - let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`; - for (let i = 0; i < points.length - 1; i += 1) { - const p0 = points[i - 1] ?? points[i]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[i + 2] ?? p2; - // Catmull-Rom (tension 1/6) → cubic Bézier 제어점 - const c1x = p1.x + (p2.x - p0.x) / 6; - const c1y = p1.y + (p2.y - p0.y) / 6; - const c2x = p2.x - (p3.x - p1.x) / 6; - const c2y = p2.y - (p3.y - p1.y) / 6; - d += - ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ` + - `${c2x.toFixed(1)},${c2y.toFixed(1)} ` + - `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; - } - return d; -} - -/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */ -export function createLineChart(opts: LineChartOptions): HTMLDivElement { - const w = opts.width ?? CHART_W; - const h = opts.height ?? CHART_H; - const yMax = opts.yMax ?? 100; - const yUnit = opts.yUnit ?? "%"; - const wrap = el("div", { className: "ui-chart" }); - - const pointCount = Math.max(0, ...opts.series.map((s) => s.values.length)); - if (pointCount < 2) { - wrap.append(el("div", { className: "ui-chart__empty", text: "—" })); - wrap.setAttribute("data-empty", "true"); - return wrap; - } - - const plotW = w - CHART_PAD.left - CHART_PAD.right; - const plotH = h - CHART_PAD.top - CHART_PAD.bottom; - const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1); - const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax); - - const svgNs = "http://www.w3.org/2000/svg"; - const svg = document.createElementNS(svgNs, "svg"); - svg.setAttribute("class", "ui-chart__svg"); - svg.setAttribute("viewBox", `0 0 ${w} ${h}`); - svg.setAttribute("role", "img"); - svg.setAttribute("width", "100%"); - svg.setAttribute("height", "100%"); - svg.setAttribute("preserveAspectRatio", "none"); - if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel); - - // y축 그리드 + 라벨 (0, 25, 50, 75, 100%) - for (let g = 0; g <= 4; g += 1) { - const v = (yMax / 4) * g; - const y = yAt(v); - const line = document.createElementNS(svgNs, "line"); - line.setAttribute("class", "ui-chart__grid"); - line.setAttribute("x1", String(CHART_PAD.left)); - line.setAttribute("x2", String(w - CHART_PAD.right)); - line.setAttribute("y1", String(y)); - line.setAttribute("y2", String(y)); - svg.append(line); - const tick = document.createElementNS(svgNs, "text"); - tick.setAttribute("class", "ui-chart__tick"); - tick.setAttribute("x", String(CHART_PAD.left - 6)); - tick.setAttribute("y", String(y + 4)); - tick.setAttribute("text-anchor", "end"); - tick.textContent = `${Math.round(v)}${yUnit}`; - svg.append(tick); - } - - // x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) + 수직 점선 그리드 - if (opts.xLabels && opts.xLabels.length > 0) { - const labels = opts.xLabels; - const baseY = CHART_PAD.top + plotH; - for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) { - const label = labels[idx]; - const x = xAt(idx); - if (label !== undefined && label !== "") { - // 수직 점선 그리드 - const vline = document.createElementNS(svgNs, "line"); - vline.setAttribute("class", "ui-chart__grid ui-chart__grid--vertical"); - vline.setAttribute("x1", String(x)); - vline.setAttribute("x2", String(x)); - vline.setAttribute("y1", String(CHART_PAD.top)); - vline.setAttribute("y2", String(baseY)); - svg.append(vline); - // x축 라벨 - const tick = document.createElementNS(svgNs, "text"); - tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x"); - tick.setAttribute("x", String(x)); - tick.setAttribute("y", String(baseY + 16)); - tick.setAttribute("text-anchor", "middle"); - tick.textContent = label; - svg.append(tick); - } - } - } - - // 시리즈별 스플라인 (null 구간은 연속 세그먼트로 나눠 각각 곡선 처리) - for (const s of opts.series) { - let segment: { x: number; y: number }[] = []; - let d = ""; - const flush = () => { - if (segment.length > 0) d += `${splinePath(segment)} `; - segment = []; - }; - s.values.forEach((v, i) => { - if (v === null || v === undefined) { - flush(); - return; - } - segment.push({ x: xAt(i), y: yAt(v) }); - }); - flush(); - const path = document.createElementNS(svgNs, "path"); - path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`); - path.setAttribute("d", d.trim()); - svg.append(path); - } - - // 범례 (그래프 영역 우상단 오버레이) - const legend = el("div", { className: "ui-chart__legend" }); - opts.series.forEach((s) => { - legend.append( - el("span", { - className: `ui-chart__legend-item ui-chart__legend-item--c${s.colorIndex ?? 0}`, - text: s.name, - }), - ); - }); - - const plot = el("div", { className: "ui-chart__plot" }); - plot.append(svg, legend); - wrap.append(plot); - return wrap; -} - -/* ============================================================================= - * 8. 기본 컴포넌트 스타일 주입 (injectBaseStyles) - * theme.css 변수만 참조. 앱 진입 시 1회 호출. - * ========================================================================== */ - -const BASE_STYLE_ID = "ui-template-elements-style"; - -const BASE_CSS = ` -[hidden] { display: none !important; } - -/* --- Button --- */ -.ui-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--spacing-8); - font-family: var(--font-body); - font-size: var(--text-body-sm); - font-weight: var(--font-weight-medium); - line-height: 1; - border: 1px solid transparent; - border-radius: var(--radius-buttons); - padding: var(--spacing-8) var(--spacing-16); - cursor: pointer; - transition: background-color var(--transition-fast), - border-color var(--transition-fast), color var(--transition-fast); -} -.ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.ui-btn__icon { display: inline-flex; width: 16px; height: 16px; } - -.ui-btn--filled { - background-color: var(--color-primary); - color: var(--color-primary-text); - box-shadow: var(--shadow-sm); -} -.ui-btn--filled:hover:not(:disabled) { background-color: var(--color-royal-amethyst); } - -.ui-btn--ghost { - background-color: transparent; - border-color: var(--color-primary); - color: var(--color-primary); -} -.ui-btn--ghost:hover:not(:disabled) { background-color: var(--color-mist-violet); } - -.ui-btn--pill { - background-color: transparent; - color: var(--color-plum-velvet); - border-radius: var(--radius-pills); -} -.ui-btn--pill:hover:not(:disabled), -.ui-btn--pill.is-active { background-color: var(--color-mist-violet); } - -.ui-btn--danger { - background-color: var(--color-danger); - color: var(--color-canvas); -} - -/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ -.ui-btn--glass { - border-color: color-mix(in srgb, var(--color-border) 65%, transparent); - background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); - color: var(--color-text-body); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} -.ui-btn--glass:hover:not(:disabled), -.ui-btn--glass.is-active { - border-color: var(--color-primary); - background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); - color: var(--color-primary-text); -} - -/* --- Input Field --- */ -.ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); } -.ui-field__label { - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); -} -.ui-input { - font-family: var(--font-body); - font-size: var(--text-body-sm); - color: var(--color-text-body); - background-color: var(--color-canvas); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - padding: 10px 14px; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.ui-input::placeholder { color: var(--color-text-muted); } -.ui-input:focus { - outline: none; - border-color: var(--color-focus-ring); - box-shadow: 0 0 0 1px var(--color-focus-ring); -} -.ui-input--error { border-color: var(--color-danger); } -.ui-field__error { - font-size: var(--text-caption); - color: var(--color-danger); - min-height: 0; - visibility: hidden; -} -.ui-field__error.is-visible { visibility: visible; } - -.ui-select { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - font-family: var(--font-body); - font-size: var(--text-body-sm); - color: var(--color-text-body); - background-color: var(--color-canvas); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - padding: 10px 36px 10px 14px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233e0079'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 16px; - cursor: pointer; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.ui-select:focus { - outline: none; - border-color: var(--color-focus-ring); - box-shadow: 0 0 0 1px var(--color-focus-ring); -} -.ui-select:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* --- Card --- */ -.ui-card { - background-color: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - padding: var(--card-padding); - box-shadow: var(--shadow-sm); -} -.ui-card--raised { box-shadow: var(--shadow-lg); border-color: transparent; } -.ui-card__title { - font-family: var(--font-display); - color: var(--color-plum-velvet); - margin-bottom: var(--spacing-16); -} -.ui-card__body { display: flex; flex-direction: column; gap: var(--spacing-16); } - -/* --- Tag / Pill --- */ -.ui-tag { - display: inline-flex; - align-items: center; - border-radius: var(--radius-pills); - padding: var(--spacing-4) var(--spacing-16); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - line-height: 1.3; -} -.ui-tag--accent { background-color: var(--color-mist-violet); color: var(--color-royal-amethyst); } -.ui-tag--neutral { background-color: var(--color-paper); color: var(--color-slate); } -.ui-tag--success { background-color: var(--color-mist-violet); color: var(--color-success); } -.ui-tag--warning { background-color: var(--color-paper); color: var(--color-warning); } -.ui-tag--danger { background-color: var(--color-paper); color: var(--color-danger); } - -/* --- Loading Overlay + Spinner --- */ -.ui-overlay { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background-color: rgba(38, 17, 74, 0.24); - z-index: var(--z-overlay); -} -.ui-overlay.is-active { display: flex; } -.ui-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--color-mist-violet); - border-top-color: var(--color-royal-amethyst); - border-radius: var(--radius-pills); - animation: ui-spin 0.8s linear infinite; -} -@keyframes ui-spin { to { transform: rotate(360deg); } } - -/* --- Toast --- */ -.ui-toast-container { - position: fixed; - top: var(--spacing-24); - right: var(--spacing-24); - display: flex; - flex-direction: column; - gap: var(--spacing-8); - z-index: var(--z-toast); -} -.ui-toast { - padding: var(--spacing-16) var(--spacing-24); - border-radius: var(--radius-cards); - font-size: var(--text-body-sm); - color: var(--color-canvas); - box-shadow: var(--shadow-lg); - opacity: 0; - transform: translateX(16px); - transition: opacity var(--transition-base), transform var(--transition-base); -} -.ui-toast.is-visible { opacity: 1; transform: translateX(0); } -.ui-toast--success { background-color: var(--color-success); } -.ui-toast--error { background-color: var(--color-danger); } -.ui-toast--warning { background-color: var(--color-warning); } -.ui-toast--info { background-color: var(--color-royal-amethyst); } - -/* --- Confirm 모달 (window.confirm 대체 — 공용 브라우저 호환) --- */ -.ui-confirm { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(38, 17, 74, 0.35); - z-index: var(--z-overlay); -} -.ui-confirm__panel { - min-width: 300px; - max-width: 420px; - padding: var(--spacing-24); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} -.ui-confirm__message { - margin: 0 0 var(--spacing-16); - color: var(--color-text-body); - font-size: var(--text-body-sm); - white-space: pre-line; -} -.ui-confirm__actions { - display: flex; - justify-content: flex-end; - gap: var(--spacing-8); -} - -/* --- Workflow Shell (3단 레이아웃) --- */ -.ui-wf { display: flex; flex-direction: column; height: 100%; } -.ui-wf__header { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--wf-header-height); - padding: 0 var(--spacing-24); - border-bottom: 1px solid var(--color-border); - background-color: var(--color-surface-raised); -} -.ui-wf__title { font-size: var(--text-subheading); } -.ui-wf__body { display: flex; flex: 1; min-height: 0; } -.ui-wf__left { - width: var(--wf-left-panel-width); - flex-shrink: 0; - padding: var(--spacing-24); - border-right: 1px solid var(--color-border); - overflow-y: auto; - background-color: var(--color-surface); -} -.ui-wf__right { - flex: 1; - min-width: 0; - overflow: auto; - background-color: var(--color-bg); -} - -/* --- Line Chart --- */ -.ui-chart { - width: 100%; -} -.ui-chart__plot { - position: relative; - width: 100%; -} -.ui-chart__svg { - width: 100%; - height: auto; - display: block; -} -.ui-chart__empty { - display: flex; - align-items: center; - justify-content: center; - min-height: 120px; - color: var(--color-text-muted); - font-size: var(--text-body); -} -.ui-chart__grid { - stroke: var(--color-border); - stroke-width: 1; - opacity: 0.5; -} -.ui-chart__grid--vertical { - stroke-dasharray: 4, 4; -} -.ui-chart__tick { - fill: var(--color-text-muted); - font-size: 12px; - font-family: var(--font-body); -} -.ui-chart__tick--x { - font-size: 11px; -} -.ui-chart__line { - fill: none; - stroke-width: 2; - stroke-linejoin: round; - stroke-linecap: round; -} -.ui-chart__line--c0 { stroke: var(--color-chart-0); } -.ui-chart__line--c1 { stroke: var(--color-chart-1); } -.ui-chart__line--c2 { stroke: var(--color-chart-2); } -.ui-chart__line--c3 { stroke: var(--color-chart-3); } -.ui-chart__point { - stroke-width: 1.5; - stroke: var(--color-surface); -} -.ui-chart__point--c0 { fill: var(--color-chart-0); } -.ui-chart__point--c1 { fill: var(--color-chart-1); } -.ui-chart__point--c2 { fill: var(--color-chart-2); } -.ui-chart__point--c3 { fill: var(--color-chart-3); } - -.ui-chart__legend { - position: absolute; - top: var(--spacing-4); - right: var(--spacing-8); - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--spacing-8) var(--spacing-16); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-cards); - background-color: color-mix(in srgb, var(--color-surface) 78%, transparent); - pointer-events: none; -} -.ui-chart__legend-item { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-caption); - color: var(--color-text); -} -.ui-chart__legend-item::before { - content: ""; - width: 12px; - height: 3px; - border-radius: 2px; - background-color: currentColor; -} -.ui-chart__legend-item--c0 { color: var(--color-chart-0); } -.ui-chart__legend-item--c1 { color: var(--color-chart-1); } -.ui-chart__legend-item--c2 { color: var(--color-chart-2); } -.ui-chart__legend-item--c3 { color: var(--color-chart-3); } -`; - -/** 공통 컴포넌트 기본 스타일을 에 1회 주입. 앱 진입 시 호출. */ -export function injectBaseStyles(): void { - if (document.getElementById(BASE_STYLE_ID)) return; - const style = el("style", { attrs: { id: BASE_STYLE_ID } }); - style.textContent = BASE_CSS; - document.head.append(style); -} diff --git a/ui_template/ui_template_elements_base.ts b/ui_template/ui_template_elements_base.ts new file mode 100644 index 00000000..a5064e04 --- /dev/null +++ b/ui_template/ui_template_elements_base.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * ui_template_elements_base.ts + * 공통 컴포넌트의 내부 유틸 — 요소 생성 헬퍼. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 차트·스타일 + * 조각과 본체가 함께 쓰므로 순환 임포트를 피하려고 맨 아래층에 둔다. + * ========================================================================== */ + +/** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ +export function el( + tag: K, + options: { + className?: string; + text?: string; + attrs?: Record; + children?: (HTMLElement | string)[]; + } = {}, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = options.text; + if (options.attrs) { + for (const [k, v] of Object.entries(options.attrs)) { + node.setAttribute(k, v); + } + } + if (options.children) { + for (const child of options.children) { + node.append(child); + } + } + return node; +} diff --git a/ui_template/ui_template_elements_chart.ts b/ui_template/ui_template_elements_chart.ts new file mode 100644 index 00000000..0685caa4 --- /dev/null +++ b/ui_template/ui_template_elements_chart.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * ui_template_elements_chart.ts + * 공통 라인 차트 — 대시보드 리소스 그래프에 쓰는 SVG 꺾은선. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 그대로 + * 다시 내보내므로 호출부의 import 경로는 바뀌지 않는다. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; + +export interface LineChartSeries { + /** 범례에 표시할 이름 (i18n 결과 문자열) */ + name: string; + /** y 값 배열 (x는 인덱스 순서, null은 결측으로 선 끊김) */ + values: (number | null)[]; + /** 선 색상 클래스 접미사: 0~3 (theme.css의 --color-chart-N 참조) */ + colorIndex?: 0 | 1 | 2 | 3; +} + +export interface LineChartOptions { + series: LineChartSeries[]; + /** x축 라벨 (values와 같은 길이 권장, 일부만 자동 선택 표기) */ + xLabels?: string[]; + /** y축 최대값 (기본: 100 = 퍼센트) */ + yMax?: number; + /** y축 단위 접미사 (기본: "%") */ + yUnit?: string; + /** 접근성 설명 */ + ariaLabel?: string; + /** 커스텀 가상 가로폭 (기본: CHART_W = 640) */ + width?: number; + /** 커스텀 가상 세로폭 (기본: CHART_H = 200) */ + height?: number; +} + +const CHART_W = 640; +const CHART_H = 200; +const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 }; +const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시) + +/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */ +function splinePath(points: { x: number; y: number }[]): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M${points[0].x},${points[0].y}`; + let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`; + for (let i = 0; i < points.length - 1; i += 1) { + const p0 = points[i - 1] ?? points[i]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2] ?? p2; + // Catmull-Rom (tension 1/6) → cubic Bézier 제어점 + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = p1.y + (p2.y - p0.y) / 6; + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = p2.y - (p3.y - p1.y) / 6; + d += + ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ` + + `${c2x.toFixed(1)},${c2y.toFixed(1)} ` + + `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; + } + return d; +} + +/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */ +export function createLineChart(opts: LineChartOptions): HTMLDivElement { + const w = opts.width ?? CHART_W; + const h = opts.height ?? CHART_H; + const yMax = opts.yMax ?? 100; + const yUnit = opts.yUnit ?? "%"; + const wrap = el("div", { className: "ui-chart" }); + + const pointCount = Math.max(0, ...opts.series.map((s) => s.values.length)); + if (pointCount < 2) { + wrap.append(el("div", { className: "ui-chart__empty", text: "—" })); + wrap.setAttribute("data-empty", "true"); + return wrap; + } + + const plotW = w - CHART_PAD.left - CHART_PAD.right; + const plotH = h - CHART_PAD.top - CHART_PAD.bottom; + const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1); + const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax); + + const svgNs = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNs, "svg"); + svg.setAttribute("class", "ui-chart__svg"); + svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + svg.setAttribute("role", "img"); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("preserveAspectRatio", "none"); + if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel); + + // y축 그리드 + 라벨 (0, 25, 50, 75, 100%) + for (let g = 0; g <= 4; g += 1) { + const v = (yMax / 4) * g; + const y = yAt(v); + const line = document.createElementNS(svgNs, "line"); + line.setAttribute("class", "ui-chart__grid"); + line.setAttribute("x1", String(CHART_PAD.left)); + line.setAttribute("x2", String(w - CHART_PAD.right)); + line.setAttribute("y1", String(y)); + line.setAttribute("y2", String(y)); + svg.append(line); + const tick = document.createElementNS(svgNs, "text"); + tick.setAttribute("class", "ui-chart__tick"); + tick.setAttribute("x", String(CHART_PAD.left - 6)); + tick.setAttribute("y", String(y + 4)); + tick.setAttribute("text-anchor", "end"); + tick.textContent = `${Math.round(v)}${yUnit}`; + svg.append(tick); + } + + // x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) + 수직 점선 그리드 + if (opts.xLabels && opts.xLabels.length > 0) { + const labels = opts.xLabels; + const baseY = CHART_PAD.top + plotH; + for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) { + const label = labels[idx]; + const x = xAt(idx); + if (label !== undefined && label !== "") { + // 수직 점선 그리드 + const vline = document.createElementNS(svgNs, "line"); + vline.setAttribute("class", "ui-chart__grid ui-chart__grid--vertical"); + vline.setAttribute("x1", String(x)); + vline.setAttribute("x2", String(x)); + vline.setAttribute("y1", String(CHART_PAD.top)); + vline.setAttribute("y2", String(baseY)); + svg.append(vline); + // x축 라벨 + const tick = document.createElementNS(svgNs, "text"); + tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x"); + tick.setAttribute("x", String(x)); + tick.setAttribute("y", String(baseY + 16)); + tick.setAttribute("text-anchor", "middle"); + tick.textContent = label; + svg.append(tick); + } + } + } + + // 시리즈별 스플라인 (null 구간은 연속 세그먼트로 나눠 각각 곡선 처리) + for (const s of opts.series) { + let segment: { x: number; y: number }[] = []; + let d = ""; + const flush = () => { + if (segment.length > 0) d += `${splinePath(segment)} `; + segment = []; + }; + s.values.forEach((v, i) => { + if (v === null || v === undefined) { + flush(); + return; + } + segment.push({ x: xAt(i), y: yAt(v) }); + }); + flush(); + const path = document.createElementNS(svgNs, "path"); + path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`); + path.setAttribute("d", d.trim()); + svg.append(path); + } + + // 범례 (그래프 영역 우상단 오버레이) + const legend = el("div", { className: "ui-chart__legend" }); + opts.series.forEach((s) => { + legend.append( + el("span", { + className: `ui-chart__legend-item ui-chart__legend-item--c${s.colorIndex ?? 0}`, + text: s.name, + }), + ); + }); + + const plot = el("div", { className: "ui-chart__plot" }); + plot.append(svg, legend); + wrap.append(plot); + return wrap; +} + +/* ============================================================================= + * 8. 기본 컴포넌트 스타일 주입 (injectBaseStyles) + * theme.css 변수만 참조. 앱 진입 시 1회 호출. + * ========================================================================== */ + diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts new file mode 100644 index 00000000..4e89a9ea --- /dev/null +++ b/ui_template/ui_template_elements_styles.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * ui_template_elements_styles.ts + * 공통 컴포넌트 기본 스타일 규칙 — injectBaseStyles() 로 1회 주입한다. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 규칙 문자열은 + * 그대로이고, 본체가 다시 내보내므로 호출부는 바뀌지 않는다. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; + +const BASE_STYLE_ID = "ui-template-elements-style"; + +const BASE_CSS = ` +[hidden] { display: none !important; } + +/* --- Button --- */ +.ui-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + font-family: var(--font-body); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-medium); + line-height: 1; + border: 1px solid transparent; + border-radius: var(--radius-buttons); + padding: var(--spacing-8) var(--spacing-16); + cursor: pointer; + transition: background-color var(--transition-fast), + border-color var(--transition-fast), color var(--transition-fast); +} +.ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.ui-btn__icon { display: inline-flex; width: 16px; height: 16px; } + +.ui-btn--filled { + background-color: var(--color-primary); + color: var(--color-primary-text); + box-shadow: var(--shadow-sm); +} +.ui-btn--filled:hover:not(:disabled) { background-color: var(--color-royal-amethyst); } + +.ui-btn--ghost { + background-color: transparent; + border-color: var(--color-primary); + color: var(--color-primary); +} +.ui-btn--ghost:hover:not(:disabled) { background-color: var(--color-mist-violet); } + +.ui-btn--pill { + background-color: transparent; + color: var(--color-plum-velvet); + border-radius: var(--radius-pills); +} +.ui-btn--pill:hover:not(:disabled), +.ui-btn--pill.is-active { background-color: var(--color-mist-violet); } + +.ui-btn--danger { + background-color: var(--color-danger); + color: var(--color-canvas); +} + +/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ +.ui-btn--glass { + border-color: color-mix(in srgb, var(--color-border) 65%, transparent); + background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + color: var(--color-text-body); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.ui-btn--glass:hover:not(:disabled), +.ui-btn--glass.is-active { + border-color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); + color: var(--color-primary-text); +} + +/* --- Input Field --- */ +.ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); } +.ui-field__label { + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} +.ui-input { + font-family: var(--font-body); + font-size: var(--text-body-sm); + color: var(--color-text-body); + background-color: var(--color-canvas); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + padding: 10px 14px; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.ui-input::placeholder { color: var(--color-text-muted); } +.ui-input:focus { + outline: none; + border-color: var(--color-focus-ring); + box-shadow: 0 0 0 1px var(--color-focus-ring); +} +.ui-input--error { border-color: var(--color-danger); } +.ui-field__error { + font-size: var(--text-caption); + color: var(--color-danger); + min-height: 0; + visibility: hidden; +} +.ui-field__error.is-visible { visibility: visible; } + +.ui-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + font-family: var(--font-body); + font-size: var(--text-body-sm); + color: var(--color-text-body); + background-color: var(--color-canvas); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + padding: 10px 36px 10px 14px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233e0079'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 16px; + cursor: pointer; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.ui-select:focus { + outline: none; + border-color: var(--color-focus-ring); + box-shadow: 0 0 0 1px var(--color-focus-ring); +} +.ui-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* --- Card --- */ +.ui-card { + background-color: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + padding: var(--card-padding); + box-shadow: var(--shadow-sm); +} +.ui-card--raised { box-shadow: var(--shadow-lg); border-color: transparent; } +.ui-card__title { + font-family: var(--font-display); + color: var(--color-plum-velvet); + margin-bottom: var(--spacing-16); +} +.ui-card__body { display: flex; flex-direction: column; gap: var(--spacing-16); } + +/* --- Tag / Pill --- */ +.ui-tag { + display: inline-flex; + align-items: center; + border-radius: var(--radius-pills); + padding: var(--spacing-4) var(--spacing-16); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + line-height: 1.3; +} +.ui-tag--accent { background-color: var(--color-mist-violet); color: var(--color-royal-amethyst); } +.ui-tag--neutral { background-color: var(--color-paper); color: var(--color-slate); } +.ui-tag--success { background-color: var(--color-mist-violet); color: var(--color-success); } +.ui-tag--warning { background-color: var(--color-paper); color: var(--color-warning); } +.ui-tag--danger { background-color: var(--color-paper); color: var(--color-danger); } + +/* --- Loading Overlay + Spinner --- */ +.ui-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background-color: rgba(38, 17, 74, 0.24); + z-index: var(--z-overlay); +} +.ui-overlay.is-active { display: flex; } +.ui-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--color-mist-violet); + border-top-color: var(--color-royal-amethyst); + border-radius: var(--radius-pills); + animation: ui-spin 0.8s linear infinite; +} +@keyframes ui-spin { to { transform: rotate(360deg); } } + +/* --- Toast --- */ +.ui-toast-container { + position: fixed; + top: var(--spacing-24); + right: var(--spacing-24); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + z-index: var(--z-toast); +} +.ui-toast { + padding: var(--spacing-16) var(--spacing-24); + border-radius: var(--radius-cards); + font-size: var(--text-body-sm); + color: var(--color-canvas); + box-shadow: var(--shadow-lg); + opacity: 0; + transform: translateX(16px); + transition: opacity var(--transition-base), transform var(--transition-base); +} +.ui-toast.is-visible { opacity: 1; transform: translateX(0); } +.ui-toast--success { background-color: var(--color-success); } +.ui-toast--error { background-color: var(--color-danger); } +.ui-toast--warning { background-color: var(--color-warning); } +.ui-toast--info { background-color: var(--color-royal-amethyst); } + +/* --- Confirm 모달 (window.confirm 대체 — 공용 브라우저 호환) --- */ +.ui-confirm { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(38, 17, 74, 0.35); + z-index: var(--z-confirm); +} +.ui-confirm__panel { + min-width: 300px; + max-width: 420px; + padding: var(--spacing-24); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} +.ui-confirm__message { + margin: 0 0 var(--spacing-16); + color: var(--color-text-body); + font-size: var(--text-body-sm); + white-space: pre-line; +} +.ui-confirm__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +/* --- Workflow Shell (3단 레이아웃) --- */ +.ui-wf { display: flex; flex-direction: column; height: 100%; } +.ui-wf__header { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--wf-header-height); + padding: 0 var(--spacing-24); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-surface-raised); +} +.ui-wf__title { font-size: var(--text-subheading); } +.ui-wf__body { display: flex; flex: 1; min-height: 0; } +.ui-wf__left { + width: var(--wf-left-panel-width); + flex-shrink: 0; + padding: var(--spacing-24); + border-right: 1px solid var(--color-border); + overflow-y: auto; + background-color: var(--color-surface); +} +.ui-wf__right { + flex: 1; + min-width: 0; + overflow: auto; + background-color: var(--color-bg); +} + +/* --- Line Chart --- */ +.ui-chart { + width: 100%; +} +.ui-chart__plot { + position: relative; + width: 100%; +} +.ui-chart__svg { + width: 100%; + height: auto; + display: block; +} +.ui-chart__empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + color: var(--color-text-muted); + font-size: var(--text-body); +} +.ui-chart__grid { + stroke: var(--color-border); + stroke-width: 1; + opacity: 0.5; +} +.ui-chart__grid--vertical { + stroke-dasharray: 4, 4; +} +.ui-chart__tick { + fill: var(--color-text-muted); + font-size: 12px; + font-family: var(--font-body); +} +.ui-chart__tick--x { + font-size: 11px; +} +.ui-chart__line { + fill: none; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.ui-chart__line--c0 { stroke: var(--color-chart-0); } +.ui-chart__line--c1 { stroke: var(--color-chart-1); } +.ui-chart__line--c2 { stroke: var(--color-chart-2); } +.ui-chart__line--c3 { stroke: var(--color-chart-3); } +.ui-chart__point { + stroke-width: 1.5; + stroke: var(--color-surface); +} +.ui-chart__point--c0 { fill: var(--color-chart-0); } +.ui-chart__point--c1 { fill: var(--color-chart-1); } +.ui-chart__point--c2 { fill: var(--color-chart-2); } +.ui-chart__point--c3 { fill: var(--color-chart-3); } + +.ui-chart__legend { + position: absolute; + top: var(--spacing-4); + right: var(--spacing-8); + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-cards); + background-color: color-mix(in srgb, var(--color-surface) 78%, transparent); + pointer-events: none; +} +.ui-chart__legend-item { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-caption); + color: var(--color-text); +} +.ui-chart__legend-item::before { + content: ""; + width: 12px; + height: 3px; + border-radius: 2px; + background-color: currentColor; +} +.ui-chart__legend-item--c0 { color: var(--color-chart-0); } +.ui-chart__legend-item--c1 { color: var(--color-chart-1); } +.ui-chart__legend-item--c2 { color: var(--color-chart-2); } +.ui-chart__legend-item--c3 { color: var(--color-chart-3); } +`; + +/** 공통 컴포넌트 기본 스타일을 에 1회 주입. 앱 진입 시 호출. */ +export function injectBaseStyles(): void { + if (document.getElementById(BASE_STYLE_ID)) return; + const style = el("style", { attrs: { id: BASE_STYLE_ID } }); + style.textContent = BASE_CSS; + document.head.append(style); +} diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 97b8a443..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"], @@ -224,9 +228,45 @@ export const ui_locales_b1 = { "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], - B03_File_Select_Hint: [ - "계획노선(CSV 또는 shapefile 5개)과 LAS/LAZ 1개, 지형 PRJ·TFW를 함께 고르면 카드에 나뉩니다. TIF는 선택 사항입니다.", - "Pick the planned route (a CSV or the five shapefile files), one LAS/LAZ, and the terrain PRJ and TFW together — they are sorted into cards. TIF is optional.", + /* --- 좌측 안내 패널 (2026-09-03) --- */ + B03_Guide_Files_Title: ["필요한 파일", "Files you need"], + B03_Guide_Files_Route: [ + "계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.", + "Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).", + ], + B03_Guide_Files_Terrain: [ + "지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.", + "Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.", + ], + B03_Guide_Files_Optional: [ + "지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.", + "The terrain TIF (DEM) is optional — surface analysis runs without it.", + ], + B03_Guide_How_Title: ["고르는 방법", "How to pick"], + B03_Guide_How_Drop: [ + "여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.", + "Drop several files at once — each is assigned to a card by its extension.", + ], + B03_Guide_How_Card: [ + "카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.", + "Use a card's [Select] to set one slot, or ✕ to clear it.", + ], + B03_Guide_How_Temp: [ + "[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.", + "[Temporary storage] pulls files you staged on the dashboard.", + ], + B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"], + B03_Guide_Notes_Replace: [ + "이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.", + "Uploading again to a finished project replaces the existing analysis.", + ], + B03_Guide_Notes_Crs: [ + "작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.", + "The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.", + ], + B03_Guide_Notes_LasFree: [ + "라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.", + "With no LiDAR, turn on [Design without LAS] and work from sheet contours.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], @@ -268,10 +308,6 @@ export const ui_locales_b1 = { B03_File_Group_Inputs: ["입력 자료", "Input files"], B03_File_Group_Route: ["계획노선 자료", "Planned route files"], B03_File_Group_Terrain: ["지형 자료 (LAS)", "Terrain files (LAS)"], - B03_File_Group_Route_Hint: [ - "shapefile은 파일 5개가 한 벌입니다. CSV 한 장으로 넣어도 됩니다.", - "A shapefile is a set of five files. A single CSV also works.", - ], B03_File_Group_Terrain_Hint: [ "여기의 PRJ·TFW는 지형 자료의 좌표계입니다 — 노선 PRJ와 별개입니다.", "The PRJ and TFW here describe the terrain data, separate from the route PRJ.", @@ -295,9 +331,7 @@ export const ui_locales_b1 = { B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ - B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], - B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"], - B03_Temp_Selected: ["선택됨:", "Selected:"], + B03_Temp_Btn_Open: ["임시 보관함", "Temporary storage"], B03_Temp_FileCount: ["개 파일", " files"], B03_Temp_Modal_Title: ["보관 자료 선택", "Select stored files"], B03_Temp_Modal_Empty: [ @@ -316,6 +350,16 @@ export const ui_locales_b1 = { "Files moved, but analysis did not start (no point cloud file).", ], + B03_File_Preview_Extent: ["자료 범위", "Data extent"], + B03_File_Preview_Route: ["계획노선 형상", "Planned route shape"], + B03_File_Preview_Outside: [ + "계획노선이 지형 자료 범위를 벗어납니다.", + "The planned route falls outside the terrain data extent.", + ], + B03_File_Preview_Inside: [ + "계획노선이 지형 자료 범위 안에 있습니다.", + "The planned route is inside the terrain data extent.", + ], B03_File_Card_Select: ["파일 선택", "Select file"], B03_File_Card_Remove: ["파일 제거", "Remove file"], B03_File_Card_Optional: ["선택", "Optional"], @@ -324,8 +368,8 @@ export const ui_locales_b1 = { "A file for this slot is already selected.", ], B03_File_Error_RequiredSlots: [ - "필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.", - "Fill every required card: the planned route (a CSV or a full shapefile set), " + + "필수 카드를 모두 채우세요 — 계획노선(shapefile 한 벌 또는 CSV), LAS/LAZ, 지형 PRJ·TFW.", + "Fill every required card: the planned route (a full shapefile set or a CSV), " + "LAS/LAZ, and the terrain PRJ and TFW.", ], B03_File_Error_SlotType: [ diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 08f2f002..aac5b82c 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -341,6 +341,19 @@ export const ui_locales_b2 = { "측점에 지반유형을 지정하면 유토곡선이 표시됩니다.", "The mass haul diagram appears once ground types are assigned to stations.", ], + /* 사면이 계산 반폭(지반 샘플 범위) 끝에서도 원지반과 안 만난 측점 경고. + 계산은 그대로 두고 사실만 알린다(2026-09-03 사용자 확정). */ + B06_Cross_SlopeUnclosed: ["사면 미교차", "Slope not closed"], + B06_Cross_SlopeUnclosed_Tip: [ + "사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.", + "The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.", + ], + /* 성토사면 경사길이 — 구조물이 사면을 끊기 전 **본래** 길이(2026-09-03 사용자 지시). */ + B06_Cross_FillSlope: ["성토사면", "Fill slope"], + B06_Cross_FillSlope_Tip: [ + "노견 끝(그 측에 측구가 있으면 측구 바깥)부터 성토 사면이 원지반과 만나는 곳까지의 경사길이입니다. 기슭막이·집수정이 사면을 끊기 전 본래 길이이며, 양측 성토면 좌·우를 함께 적습니다. 5m를 넘으면 옹벽·석축 등이 필요합니다(성토_비탈면 §2). 「≥」는 사면이 계산 반폭 안에서 원지반과 만나지 못해 거기까지만 잰 하한값입니다.", + "Slant length of the fill slope, from the shoulder edge (outside the ditch if that side has one) to where it meets existing ground. It is the original slope before any revetment or catch basin cuts it short, and both sides are shown when the section is filled on both. Over 5 m a retaining structure is required. A leading ≥ marks a lower bound: the slope never meets ground within the computed half-width, so only that much could be measured.", + ], B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"], B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"], B06_MassHaul_Fill: ["성토", "Fill"], @@ -348,6 +361,20 @@ export const ui_locales_b2 = { B06_MassHaul_Shortage: ["토취", "Borrow"], /* 운반계획 도면은 다짐상태 기준으로 계산한다(국도건설공사 설계실무 요령). */ B06_MassHaul_Basis: ["다짐상태 기준", "Compacted basis"], + /* 최종 누가토량 = 절·성토 균형 지표. 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다 + (2026-09-03 사용자 확정). 실무 유토곡선 6건 관측치는 +49~+324㎥ 범위다 + — resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md §3 */ + B06_MassHaul_FinalCumulative: ["최종 누가토량", "Final cumulative"], + B06_MassHaul_FinalCumulative_Tip: [ + "노선 끝의 누가토량 — 0에 가까울수록 절·성토 균형. 곡선이 0선을 넘지 않으면 평형선·운반 블록이 생기지 않습니다.", + "Cumulative volume at the end of the route — the closer to zero, the better cut/fill balance. No balance lines or haul blocks appear while the curve never crosses zero.", + ], + /* 저장된 횡단이 옛 계획고로 굳어 있어 재계산을 기다리는 동안 — 옛 값으로 그린 곡선을 + 보여 주지 않는다(2026-09-03 사용자 확정: 「새 값만 보여주기」). */ + B06_MassHaul_Recalculating: [ + "횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.", + "Recalculating cross sections against the current profile.", + ], B06_MassHaul_AllHidden: [ "표시할 곡선을 하나 이상 켜 주세요.", "Turn on at least one curve to display.", @@ -479,6 +506,10 @@ export const ui_locales_b2 = { B06_Std_Group_Soil: ["토사 구간", "Soil section"], B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], B06_Std_Group_Paved: ["포장 구간", "Paved section"], + B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"], + B06_Std_Section_Common: ["공통", "Common"], + B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"], + B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"], B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"], @@ -555,6 +586,13 @@ export const ui_locales_b2 = { B07_Info_Confirmed: ["확정", "Confirmed"], B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."], B07_Info_Station: ["측점", "Station"], + /* 장(여러 측점을 담은 횡단 도면)은 측점 단위 지반·계획 정보를 갖지 않는다 — + 제목을 「측점」으로 달면 어느 측점 값인지 오해된다(2026-09-03 정리). */ + B07_Info_Sheet: ["장", "Sheet"], + B07_Info_SheetHint: [ + "이 장은 측점 여러 개를 담습니다. 지반·계획 정보는 측점 도면에서 봅니다.", + "This sheet holds several stations. Ground and plan details live on each station drawing.", + ], /* --- B08_Quantity 수량 산출 --- */ B08_Quantity_Title: ["수량산출", "Quantity"], @@ -571,9 +609,224 @@ export const ui_locales_b2 = { "수량 단계 확정에 실패했습니다.", "Failed to confirm the quantity stage.", ], + B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"], + B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"], + B08_Quantity_Grid_Empty: [ + "측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.", + "No cross-section areas yet. Finish the cross-section design first.", + ], + B08_Quantity_Grid_Failed: [ + "토적표를 불러오지 못했습니다.", + "Failed to load the earthwork table.", + ], + B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], + B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], + B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"], + B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"], + B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"], + B08_Quantity_Side_Method_Label: ["시공법", "Method"], + B08_Quantity_Method_Unset: ["안 정함", "Not set"], + B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], + B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], + B08_Quantity_Placing_NotApplied: [ + "콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.", + "The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.", + ], + B08_Quantity_Dev_Title: [ + "⚠ 개발 전용 — 확정을 건너뛰고 다음 단계를 엽니다(계산은 돌지 않습니다).", + "⚠ Dev only — unlocks the next stage without confirming (no recalculation).", + ], + B08_Quantity_Dev_Unlock: ["확정 없이 다음으로", "Skip confirm"], + B08_Quantity_Dev_Relock: ["원래대로 되돌리기", "Undo skip"], + B08_Quantity_Dev_Bypassed: [ + "지금 확정을 건너뛴 상태입니다 — 값이 비어 보이는 것은 정상입니다. 건너뛴 단계", + "Currently skipping confirmation — empty values are expected. Skipped stages", + ], + B08_Quantity_Dev_Normal: ["건너뛴 단계 없음 (정상 상태)", "No skipped stages"], + B08_Quantity_Dev_Done: ["단계 잠금을 바꿨습니다.", "Stage lock updated."], + B08_Quantity_Dev_Failed: ["단계 잠금을 바꾸지 못했습니다.", "Failed to update stage lock."], + B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"], + B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"], + B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"], + B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], + B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"], + B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"], + B08_Quantity_Placing_Current: ["적용 중", "In use"], + B08_Quantity_Placing_Default_Tag: ["기본값 — 확인 필요", "default — needs review"], + B08_Quantity_Placing_Hint: ["참고 단가(B09 산출):", "Reference unit price (from B09):"], + B08_Quantity_Placing_Ready: ["레디믹스트", "Ready-mixed"], + B08_Quantity_Placing_Machine: ["기계비빔", "Machine-mixed"], + B08_Quantity_Placing_Hand: ["인력비빔", "Hand-mixed"], + B08_Quantity_Placing_Default_Notice: [ + "⚠ 기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값입니다. 타설 방식에 따라 단가가 갈립니다.", + "⚠ Using the default (ready-mixed) — not yet decided. Unit prices differ by method.", + ], + B08_Quantity_Material_Failed: [ + "자재총괄을 불러오지 못했습니다.", + "Failed to load the material summary.", + ], + B08_Quantity_Haul_Missing: [ + "운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.", + "No haul plan yet. Press [Confirm] on the profile design to build it.", + ], + B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"], + B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"], + /* 반영률 항목 이름 — 서버 키를 사람이 읽는 말로. 거창 실무 시트 문구를 따른다. */ + B08_Quantity_Ratio_FillCompaction: ["성토면다짐", "Fill slope compaction"], + B08_Quantity_Ratio_SeedFill: ["초류종자살포(성토면)", "Seed spray (fill)"], + B08_Quantity_Ratio_SeedCut: ["초류종자살포(절토면)", "Seed spray (cut)"], + B08_Quantity_Ratio_TreeRemoval: ["지장목제거", "Obstacle removal"], + B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"], + B08_Quantity_Side_RockRatios: [ + "암 갈래 구성비(%) — 암 총량 기준", + "Rock class split (%) of rock volume", + ], + B08_Quantity_Btn_Save: ["저장", "Save"], + B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."], + B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."], + B08_Quantity_Unsaved: ["저장하지 않은 변경이 있습니다.", "You have unsaved changes."], + B08_Quantity_Side_Method: ["산출법", "Method"], + B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"], + B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"], - /* --- B09_Estimation 견적·문서 --- */ - B09_Estimation_Title: ["설계도서", "Design Docs"], + /* --- B09_Estimation 원가계산 --- */ + B09_Estimation_Title: ["원가계산", "Cost Estimate"], + B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], + B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], + B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"], + B09_Estimation_PB_Empty: [ + "설계내역서를 먼저 불러오면 단가산출서가 섭니다.", + "Load the bill first and the price-basis sheets appear.", + ], + B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."], + B09_Estimation_PB_Ref: ["참조", "Refers to"], + B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"], + B09_Estimation_Mat_Owner: [ + "관급 자재 — 총원가 밖 별도 표기", + "Owner-supplied — listed outside the total cost", + ], + B09_Estimation_Mat_Unknown: [ + "관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다", + "Supply type undecided — excluded from both totals", + ], + B09_Estimation_Mat_None: [ + "이 프로젝트에는 자재 줄이 없습니다 — 구조물이 없거나 자재가 안 나온 상태입니다.", + "This project has no material rows yet.", + ], + B09_Estimation_Mat_Empty: [ + "설계내역서를 먼저 불러오면 자재대가 섭니다.", + "Load the bill first and the material sheet appears.", + ], + B09_Estimation_Boq_Precision: [ + "수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.", + "Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.", + ], + B09_Estimation_Boq_Excluded: [ + "검산용 줄 — 수량만 보이고 금액을 매기지 않습니다", + "Check rows — quantity only, never priced", + ], + B09_Estimation_Boq_NotOurs: [ + "여기서 세지 않는 줄 (다른 표에 있거나 이 노선에 없음)", + "Counted elsewhere or not present on this route", + ], + B09_Estimation_Boq_NeedsInput: [ + "입력하면 풀리는 것 (설계 화면에서 값을 고르면 금액이 섭니다)", + "Waiting on input — pick the value on the design screen and the amount appears", + ], + B09_Estimation_Boq_NeedsWork: [ + "우리가 만들어야 하는 것 (원단위·전개식 없음)", + "Needs build — unit data or formula is missing", + ], + B09_Estimation_Boq_Missing: [ + "금액을 못 세운 줄 — 0 으로 안 채웁니다", + "Rows without an amount — shown as-is, not zero-filled", + ], + B09_Estimation_Boq_Materials: ["자재 (별도 벌)", "Materials (separate set)"], + B09_Estimation_Boq_NoMaterialPrice: [ + "사급 자재 단가가 아직 없어 자재비가 빠져 있습니다 — 지금 합계는 모자란 값입니다.", + "Contractor-supplied material prices are missing, so material cost is absent — this total is short.", + ], + B09_Estimation_Boq_Load: ["B08 수량 불러오기", "Load B08 quantities"], + B09_Estimation_Boq_Failed: [ + "B08 인계 자료를 받지 못했습니다.", + "Could not load the B08 handoff.", + ], + B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"], + B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"], + B09_Estimation_Tab_Machine: ["중기", "Equipment"], + B09_Estimation_Tab_Duration: ["공사기간", "Duration"], + B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], + B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], + B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], + B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], + B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"], + B09_Estimation_Group_Profit: ["이윤 조정", "Profit Adjustment"], + B09_Estimation_Field_DirectMaterial: ["직접재료비", "Direct Material"], + B09_Estimation_Field_DirectLabor: ["직접노무비", "Direct Labor"], + B09_Estimation_Field_DirectExpense: ["직접경비", "Direct Expense"], + B09_Estimation_Field_Duration: ["공사기간(일)", "Duration (days)"], + B09_Estimation_Field_WorkType: ["공종", "Work Type"], + B09_Estimation_Field_OwnerMaterial: ["순자재대", "Net Material"], + B09_Estimation_Field_ProcurementFee: ["조달수수료", "Procurement Fee"], + B09_Estimation_Field_ProfitAdjust: ["조정액", "Adjustment"], + B09_Estimation_Field_TargetContract: ["목표 도급공사비", "Target Contract"], + B09_Estimation_Btn_Recalc: ["재계산", "Recalculate"], + B09_Estimation_Btn_Confirm: ["확정", "Confirm"], + B09_Estimation_Col_Item: ["비목", "Item"], + B09_Estimation_Col_Amount: ["금액", "Amount"], + B09_Estimation_Col_Rate: ["요율", "Rate"], + B09_Estimation_Col_Basis: ["산출근거", "Basis"], + B09_Estimation_Col_Note: ["비고", "Note"], + B09_Estimation_Adopted: ["채택", "Adopted"], + B09_Estimation_NotAdopted: ["미채택", "Not adopted"], + B09_Estimation_Suggest_Adjust: [ + "목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.", + "To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.", + ], + B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."], + B09_Estimation_Confirm_Success: [ + "원가계산 단계를 확정했습니다.", + "Cost estimate stage confirmed.", + ], + B09_Estimation_Confirm_Failed: [ + "원가계산 단계 확정에 실패했습니다.", + "Failed to confirm the cost estimate stage.", + ], + B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"], + B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"], + B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"], + B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."], + B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"], + B09_Estimation_Col_Name: ["명칭", "Name"], + B09_Estimation_Col_Spec: ["규격", "Spec"], + B09_Estimation_Col_Unit: ["단위", "Unit"], + B09_Estimation_Col_Qty: ["수량", "Qty"], + B09_Estimation_Col_Source: ["원천", "Source"], + B09_Estimation_Col_Material: ["재료비", "Material"], + B09_Estimation_Col_Labor: ["노무비", "Labor"], + B09_Estimation_Col_Expense: ["경비", "Expense"], + B09_Estimation_Col_Total: ["합계", "Total"], + B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"], + B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"], + B09_Estimation_UP_RoundGap: [ + "행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:", + "Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:", + ], + B09_Estimation_Group_Quantity: ["수량", "Quantities"], + B09_Estimation_Field_Quantities: [ + "공종별 수량 (한 줄에 「공종코드=수량」)", + 'Quantities (one "code=qty" per line)', + ], + B09_Estimation_Src_Manual: ["수량 원천: 손입력(직접비 직접 입력)", "Source: manual direct costs"], + B09_Estimation_Src_Quantities: [ + "수량 원천: 손입력 공종 수량 × 일위대가", + "Source: manual quantities × unit prices", + ], + B09_Estimation_Missing_UP: [ + "수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:", + "Quantities without a unit price — excluded from the total:", + ], + B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."], /* --- B10_Payment 결재 --- */ B10_Payment_Title: ["결재", "Payment"], diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index dde3aa13..11eb7dfc 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -82,7 +82,7 @@ export const ui_locales_common = { WF_Step_ProfileCross: ["횡단설계", "Cross Design"], WF_Step_DesignDetail: ["상세설계", "Detail Design"], WF_Step_Quantity: ["수량산출", "Quantity"], - WF_Step_Estimation: ["설계도서", "Design Docs"], + WF_Step_Estimation: ["원가계산", "Cost Estimate"], WF_State_Stale: ["무효화됨 (하위 단계 변경)", "Stale (invalidated by downstream changes)"], WF_State_Failed: ["실패", "Failed"], WF_State_Complete: ["완료", "Complete"], diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index b139123a..c0e2062c 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -59,6 +59,21 @@ width: calc(var(--wf-left-panel-width) / 2); } +/* 끌어 옮기기 (2026-09-04) — 헤더를 잡아 끈다. 자리를 옮기면 인라인 left/top이 + 위 고정값을 덮어쓴다. 아래 공간이 모자라면 `is-flip-up`으로 헤더 위로 펼친다. */ +.ui-workflow-overlay__panel--progress .ui-workflow-overlay__header { + cursor: move; + touch-action: none; +} + +.ui-workflow-overlay__panel--progress.is-flip-up { + flex-direction: column-reverse; +} + +.ui-workflow-overlay__panel--progress.is-dragging { + user-select: none; +} + .ui-workflow-overlay__header { display: flex; align-items: center; @@ -78,6 +93,24 @@ cursor: pointer; } +/* 제목 줄 오른쪽 프로젝트 이름 (2026-09-04 사용자 지시) — 길면 말줄임, 전체는 툴팁. + 줄어드는 쪽은 이름만 — 페이지 제목은 짧은 고정 문구라 밀리면 안 된다. */ +.ui-workflow-overlay__panel--title .ui-workflow-overlay__title { + flex: 0 0 auto; +} + +.ui-workflow-overlay__project { + flex: 0 1 auto; + max-width: 60%; + min-width: 0; + overflow: hidden; + color: var(--color-text-muted, var(--color-text)); + font-size: var(--text-body-sm); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + .ui-workflow-overlay__toggle { flex: 0 0 auto; width: var(--spacing-32); diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 3deb4200..3fe0881b 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -1,8 +1,15 @@ 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"; +// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 +// 두 번 받는다(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"; +const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; @@ -78,7 +85,7 @@ export function createWorkflowPanelHandle( } function readOpenState(key: string): boolean { - return sessionStorage.getItem(key) !== "false"; + return readByKey(key) !== "false"; } /** @@ -108,12 +115,38 @@ function splitSidebarActions(body: HTMLElement): void { } } +/** + * 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시). + * + * 화면이 들고 있는 것은 프로젝트 id 뿐이라 워크플로 상태 응답에서 이름을 받아 온다 — + * 새로고침·주소 직접 입력으로 들어와도 따라온다. 이름이 없으면 칸을 비워 둔다. + * 여기 한 곳에 두어 제목 패널을 쓰는 화면(B03~B08)이 모두 같은 값을 보인다. + */ +function createProjectNameTag(): HTMLElement { + const tag = document.createElement("span"); + tag.className = "ui-workflow-overlay__project"; + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + if (!projectId) return tag; + void fetchWorkflowState(projectId) + .then((state) => { + const name = state.project_name ?? ""; + tag.textContent = name; + // 이름이 길면 말줄임으로 자르고 전체는 툴팁으로 본다. + if (name) tag.title = name; + }) + .catch(() => { + /* 조회 실패는 제목만 보이면 된다 — 빈 칸으로 둔다. */ + }); + return tag; +} + function createPanel( variant: "title" | "progress", titleText: string, body: HTMLElement, storageKey: string, onOpenChange?: (isOpen: boolean) => void, + titleAside?: HTMLElement, ): { root: HTMLElement; setOpen: (isOpen: boolean) => void } { const root = document.createElement("aside"); root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`; @@ -135,6 +168,8 @@ function createPanel( if (isSidebar) { header.append(title); + // 제목은 왼쪽, 프로젝트 이름은 같은 행 오른쪽 끝 (2026-09-04 사용자 지시). + if (titleAside) header.append(titleAside); root.append(header, toggle); } else { header.append(title, toggle); @@ -146,6 +181,8 @@ function createPanel( root.append(body); } + let dragHandle: { refresh: () => void } | null = null; + function setOpen(isOpen: boolean): void { root.classList.toggle("is-collapsed", !isOpen); if (panelHandle) panelHandle.setOpen(isOpen); @@ -155,7 +192,9 @@ 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); } @@ -174,6 +213,10 @@ function createPanel( }); toggle.addEventListener("click", toggleOpen); setOpen(readOpenState(storageKey)); + // 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바). + if (variant === "progress") { + dragHandle = makePanelDraggable(root, header, PROGRESS_OVERLAY_POSITION_KEY); + } return { root, setOpen }; } @@ -200,6 +243,7 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo titleBody, TITLE_OVERLAY_STATE_KEY, options.onOptionsOpenChange, + createProjectNameTag(), ); root.append(titlePanel.root); setTitleOpen = titlePanel.setOpen; diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts new file mode 100644 index 00000000..35e205cc --- /dev/null +++ b/ui_template/ui_template_overlay_drag.ts @@ -0,0 +1,171 @@ +/** + * 진행단계 패널 끌어 옮기기 (2026-09-04 사용자 지시). + * + * 패널은 `position: fixed`라 좌표계가 곧 뷰포트다. 이동 범위는 **상단 헤더 아래 + * 화면 안**으로 잡아, 패널을 화면 밖으로 흘려 다시 못 잡는 상태를 원천 차단한다. + * + * 헤더에는 이미 제목 클릭 = 접기/펴기 토글이 붙어 있으므로(`ui_template_overlay.ts`) + * 이동 threshold 4px를 두고, 넘으면 드래그로 보고 뒤따르는 click 한 번을 삼킨다. + * 안 그러면 옮길 때마다 패널이 접힌다. + * + * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). + */ + +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; + +const DRAG_THRESHOLD_PX = 4; +/** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ +const MIN_TOP_PX = 64; +/** 화면 가장자리에서 남겨 둘 여백. */ +const EDGE_GAP_PX = 8; + +export interface PanelDragHandle { + /** 펼침/접힘이 바뀐 뒤 열리는 방향을 다시 판정한다. */ + refresh: () => void; +} + +interface PanelPosition { + left: number; + top: number; +} + +function readPosition(key: string): PanelPosition | null { + const raw = readByKey(key); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed?.left !== "number" || typeof parsed?.top !== "number") return null; + return { left: parsed.left, top: parsed.top }; + } catch { + return null; + } +} + +export function makePanelDraggable( + root: HTMLElement, + header: HTMLElement, + storageKey: string, +): PanelDragHandle { + let position: PanelPosition | null = readPosition(storageKey); + let pointerId: number | null = null; + let startX = 0; + let startY = 0; + let baseLeft = 0; + let baseTop = 0; + let moved = false; + + function bodyHeight(): number { + if (root.classList.contains("is-collapsed")) return 0; + const body = root.querySelector(".ui-workflow-overlay__body"); + return body ? body.offsetHeight : 0; + } + + /** 헤더 왼쪽 위 좌표를 화면 안으로 가둔다. */ + function clamp(left: number, top: number): PanelPosition { + const width = root.offsetWidth; + const headerHeight = header.offsetHeight; + const maxLeft = Math.max(0, window.innerWidth - width - EDGE_GAP_PX); + const maxTop = Math.max(MIN_TOP_PX, window.innerHeight - headerHeight - EDGE_GAP_PX); + return { + left: Math.min(Math.max(left, EDGE_GAP_PX), maxLeft), + top: Math.min(Math.max(top, MIN_TOP_PX), maxTop), + }; + } + + /** + * 자리를 인라인 좌표로 박고, 아래 공간이 모자라면 헤더 **위로** 펼치게 뒤집는다. + * 뒤집을 때는 `bottom` 기준으로 잡아 헤더가 놓아 둔 자리에 그대로 남는다. + */ + function apply(next: PanelPosition): void { + position = next; + const headerHeight = header.offsetHeight; + const body = bodyHeight(); + const spaceBelow = window.innerHeight - (next.top + headerHeight) - EDGE_GAP_PX; + const spaceAbove = next.top - MIN_TOP_PX; + const flip = body > spaceBelow && spaceAbove > spaceBelow; + + root.style.left = `${next.left}px`; + root.style.right = "auto"; + root.classList.toggle("is-flip-up", flip); + if (flip) { + root.style.top = "auto"; + root.style.bottom = `${Math.max(window.innerHeight - next.top - headerHeight, 0)}px`; + } else { + root.style.bottom = "auto"; + root.style.top = `${next.top}px`; + } + writeByKey(storageKey, JSON.stringify(next)); + } + + function swallowNextClick(): void { + const handler = (event: MouseEvent): void => { + event.stopPropagation(); + event.preventDefault(); + }; + window.addEventListener("click", handler, { capture: true, once: true }); + // 화면 밖에서 손을 떼면 click 이 안 온다 — 남은 감시자가 다음 진짜 클릭을 + // 먹지 않도록 곧 걷어낸다. click 은 pointerup 과 같은 입력 처리 차례에 오므로 + // 타이머(0ms)는 늘 그 뒤에 돈다. + window.setTimeout(() => window.removeEventListener("click", handler, true), 0); + } + + header.addEventListener("pointerdown", (event: PointerEvent) => { + if (event.button !== 0) return; + const rect = root.getBoundingClientRect(); + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + baseLeft = rect.left; + // 뒤집힌 상태에서도 기준은 늘 헤더의 위쪽 변이다. + baseTop = header.getBoundingClientRect().top; + moved = false; + // 여기서 포인터를 잡으면 뒤따르는 click 의 대상이 제목에서 헤더로 바뀌어 + // 접기/펴기 토글이 죽는다(2026-09-04 실측). 실제로 끌기 시작한 뒤에 잡는다. + }); + + // 포인터 이동은 **창 전체**에서 듣는다. 헤더에서만 들으면 커서가 패널 밖으로 + // 나가는 순간 이동이 끊기고, 헤더에 포인터를 잡아 두면(setPointerCapture) 뒤따르는 + // click 의 대상이 제목에서 헤더로 바뀌어 접기/펴기 토글이 죽는다(2026-09-04 실측). + window.addEventListener("pointermove", (event: PointerEvent) => { + if (pointerId !== event.pointerId) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + if (!moved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; + if (!moved) { + moved = true; + // 끌기가 실제로 시작된 뒤에만 포인터를 붙잡는다. B07 CAD 화면은 iframe 이라 + // 커서가 그 위로 들어가면 바깥 window 가 이동을 못 받는다(2026-09-04 실측). + // pointerdown 시점에 잡으면 뒤따르는 click 대상이 헤더로 바뀌어 토글이 죽는다. + try { + header.setPointerCapture(event.pointerId); + } catch { + /* 이미 놓친 포인터면 그대로 진행 */ + } + } + root.classList.add("is-dragging"); + apply(clamp(baseLeft + dx, baseTop + dy)); + }); + + function endDrag(event: PointerEvent): void { + if (pointerId !== event.pointerId) return; + if (moved && header.hasPointerCapture(pointerId)) header.releasePointerCapture(pointerId); + pointerId = null; + root.classList.remove("is-dragging"); + if (!moved) return; + // 놓는 시점에 열리는 방향을 다시 판정한다. + if (position) apply(clamp(position.left, position.top)); + swallowNextClick(); + } + + window.addEventListener("pointerup", endDrag); + window.addEventListener("pointercancel", endDrag); + + // 만들어진 직후에는 아직 DOM에 붙기 전이라 실측 크기가 0이다 — 한 프레임 뒤에 앉힌다. + if (position) requestAnimationFrame(() => position && apply(clamp(position.left, position.top))); + + return { + refresh: () => { + if (position) apply(clamp(position.left, position.top)); + }, + }; +} diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 9deb740c..498ad60b 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -25,6 +25,15 @@ height: var(--ui-progress-size); } +/* 회전 껍데기 — 진행률을 알든 모르든 **항상** 돈다(2026-09-04 사용자 지시). + 무거운 단계에서 호가 안 늘어도 도넛이 멈춰 보이지 않는다. 안쪽 svg는 12시 고정이라 + 호·숫자는 제자리에서 갱신된다. */ +.ui-progress-circle__spin { + width: 100%; + height: 100%; + animation: ui-progress-spin 1s linear infinite; +} + .ui-progress-circle__svg { width: 100%; height: 100%; @@ -46,17 +55,12 @@ transition: stroke-dashoffset var(--transition-base); } -/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */ -.ui-progress-circle.is-indeterminate .ui-progress-circle__svg { - animation: ui-progress-spin 1s linear infinite; -} - @keyframes ui-progress-spin { from { - transform: rotate(-90deg); + transform: rotate(0deg); } to { - transform: rotate(270deg); + transform: rotate(360deg); } } diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index 00c12cfb..048116cc 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -61,22 +61,27 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr label.className = "ui-progress-circle__label"; label.textContent = options.label ?? ""; + // 회전은 진행률과 **따로 논다**(2026-09-04 사용자 지시) — 무거운 단계에서 호가 + // 안 늘어도 도넛은 계속 돌아야 "멈춘 것"으로 안 보인다. 바깥 껍데기만 CSS로 돌리고 + // 안쪽 svg는 12시 고정이라, 호·숫자는 제자리에서 갱신된다. + const spin = document.createElement("div"); + spin.className = "ui-progress-circle__spin"; + spin.append(svg); + const dial = document.createElement("div"); dial.className = "ui-progress-circle__dial"; - dial.append(svg, percent); + dial.append(spin, percent); root.append(dial, label); function set(ratio: number | null, nextLabel?: string): void { if (nextLabel !== undefined) label.textContent = nextLabel; if (ratio === null) { - // 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다. - root.classList.add("is-indeterminate"); + // 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다). bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); percent.textContent = ""; return; } const clamped = Math.min(1, Math.max(0, ratio)); - root.classList.remove("is-indeterminate"); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); percent.textContent = `${Math.round(clamped * 100)}%`; } 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/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 2a6bfbe7..181d418e 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -197,6 +197,8 @@ --z-dropdown: 200; --z-overlay: 900; --z-modal: 1000; + /* 확인창은 모달 위에 떠야 한다 — 모달 닫기 확인이 모달 뒤에 깔리면 못 누른다. */ + --z-confirm: 1050; --z-toast: 1100; /* --------------------------------------------------------------------------- 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)