diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 30571f6dc..b3745a035 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -133,6 +133,8 @@ export const STATE_REGISTRY = { scope: "project", legacy: (p) => `aislo:structure-pick:${p}`, }, + /** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */ + "pipe-ids": { bucket: "draft", scope: "project" }, /** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */ selection: { bucket: "draft", scope: "project" }, /** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */ diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 701ab2e34..2b2a93ec6 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -29,6 +29,7 @@ import { import { currentRoute, navigateTo, type MountedPage } from "../router"; import type { DesignView } from "./design_dock"; import type { DesignPageShared } from "./design_shared"; +import { usePipeIds } from "./design_pipe_ids"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -105,6 +106,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { page.className = "design-page"; root.replaceChildren(page); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) // 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다. const left = document.createElement("div"); diff --git a/A00_Common/design/design_pick_match.ts b/A00_Common/design/design_pick_match.ts new file mode 100644 index 000000000..09bd321bf --- /dev/null +++ b/A00_Common/design/design_pick_match.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * A00_Common/design/design_pick_match.ts + * 고름 강조 한 규칙(323-5) — 그린 것(카드 형상 · 3D 부재 · 알약 · 측점선)마다 신원표 `{구조물 id · 부분 · 자리}` + * 를 달고, 고름 한 칸(`structure-pick`)과 이 함수 하나로 켜짐을 가름. + * + * ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로) + * ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값) + * ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두 + * DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은 + * 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」). + * ========================================================================== */ + +import { isNear, type DesignSelection } from "./design_selection"; + +export interface PickTag { + structureId?: string | null; + part?: string | null; + chainageM?: number | null; +} + +export type PickSelection = + (Partial & { partKey?: string | null }) | null | undefined; + +/** 이 그림이 고름에 켜지나 — 위 ① ② ③. */ +export function pickLights(selection: PickSelection, tag: PickTag): boolean { + if (!selection) return false; + let here: boolean; + if (selection.structureId && tag.structureId) here = selection.structureId === tag.structureId; + else if (selection.chainageM == null || tag.chainageM == null) return false; + else here = isNear(tag.chainageM, selection.chainageM); + if (!here) return false; + return !selection.partKey || tag.part === selection.partKey; +} + +/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */ +export function tagShape(el: Element, structureId: string | null, part?: string | null): void { + if (structureId) el.setAttribute("data-structure-id", structureId); + if (part) el.setAttribute("data-part", part); +} + +/** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) · + * 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */ +export function paintPicked(root: ParentNode, selection: PickSelection): void { + for (const shape of root.querySelectorAll("[data-structure-id]")) { + const id = shape.getAttribute("data-structure-id") ?? ""; + if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue; + if (shape.getAttribute("data-pave-type") !== null) continue; + shape.classList.toggle( + "is-active", + !!selection?.structureId && pickLights(selection, { structureId: id }), + ); + } +} diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index f03582571..795dea194 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -6,7 +6,7 @@ * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 * 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는 - * 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다. + * 그릴 때마다 지금 관 목록에서 다시 찾고 관 id 는 세션 id(323-5)라 관이 옮겨져도 끊기지 않는다. * ========================================================================== */ import { @@ -16,6 +16,7 @@ import { type DesignSelection, } from "./design_selection"; import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape"; +import { pipeIdAt } from "./design_pipe_ids"; import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; @@ -68,9 +69,7 @@ export function profilePickOf( ); if (own) return { stationId: irregularStationId(own.id) }; const target = graphTargetAt(null, at, source.pipes(), source.sections()); - return "pipeChainageM" in target - ? { structureId: `pipe-${target.pipeChainageM.toFixed(2)}` } - : target; + return "pipeChainageM" in target ? { structureId: pipeIdAt(target.pipeChainageM) } : target; } export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void { @@ -91,7 +90,9 @@ export function paintMapPick( export interface ViewerPickTarget { markers: { selectStation: (stationId: string | null, notify?: boolean) => void }; structurePick: { - select: (pick: { chainageM: number; kind: string; key?: string } | null) => void; + select: ( + pick: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null, + ) => void; }; } @@ -100,7 +101,10 @@ export interface ViewerPickTarget { export function viewerPickOf( selection: (DesignSelection & { partKey?: string }) | null, source: ProfilePickSource, -): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } { +): { + stationId: string | null; + member: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null; +} { if (!selection || selection.chainageM === null) return { stationId: null, member: null }; const at = selection.chainageM; const pick = profilePickOf(selection, source); @@ -108,7 +112,10 @@ export function viewerPickOf( ? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m) : undefined; const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null; - return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } }; + return { + stationId, + member: { chainageM: at, kind: "", key: selection.partKey, structureId: selection.structureId }, + }; } export function paintViewerPick( diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts new file mode 100644 index 000000000..7ce5f39a6 --- /dev/null +++ b/A00_Common/design/design_pipe_ids.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * A00_Common/design/design_pipe_ids.ts + * 관 세션 id(323-5) — 관을 처음 볼 때 자리마다 붙이는 id · 옮겨도 그대로 · 저장 안 함(파일 모양 그대로). + * + * id = `pipe-s` + 차례(`pipe-s3`) — 머리 `pipe-` 로 관임을 가름(옛 자리 이름 `pipe-{누가거리}` 와 같은 머리) · + * 누가거리는 id 에서 읽지 않음(`pipeChainageOf`). 장부 = 세션 `pipe-ids`(고름 `structure-pick` 과 같은 수명 · + * 초안 비우기에 함께 비움). 자리 → id = 찾는 거리(`PIPE_MATCH_M`) 안 가장 가까운 것 · 없으면 새 차례 · + * 옮김 = `movePipeId`(고름 따라가기 `followMovedPipe` 가 부름). + * 프로젝트 = 보기가 열 때 정함(`usePipeIds`) — 그리는 함수(카드 · 알약)가 프로젝트를 몰라도 같은 id. + * 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름. + * ========================================================================== */ + +import { readState, writeState } from "../b_page_state"; +import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection"; + +/** 관 찾는 거리 — 정의는 `design_selection` 한 곳(배수 패널이 관 옮김과 함께 가져감). */ +export { PIPE_MATCH_M } from "./design_selection"; + +interface Book { + next: number; + ids: Array<{ id: string; at: number }>; +} + +let active: string | null = null; + +/** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */ +export function usePipeIds(projectId: string | null): void { + active = projectId; +} + +const bookOf = (projectId: string): Book => + readState("pipe-ids", projectId) ?? { next: 1, ids: [] }; + +/** 그 자리 관의 id — 장부에 없으면 새 차례로 적음. */ +export function pipeIdAt(chainageM: number, projectId: string | null = active): string { + if (!projectId) return `pipe-${chainageM.toFixed(2)}`; + const book = bookOf(projectId); + const hit = nearestWithin(book.ids, chainageM, (entry) => entry.at); + if (hit) return hit.id; + const id = `pipe-s${book.next}`; + writeState( + "pipe-ids", + { next: book.next + 1, ids: [...book.ids, { id, at: chainageM }] }, + projectId, + ); + return id; +} + +/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */ +export function pipeChainageOf(id: string, projectId: string | null = active): number | null { + if (!id.startsWith("pipe-")) return null; + const hit = projectId ? bookOf(projectId).ids.find((entry) => entry.id === id) : undefined; + if (hit) return hit.at; + const legacy = Number(id.slice("pipe-".length)); + return Number.isFinite(legacy) ? legacy : null; +} + +/** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */ +export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string { + if (!projectId) return `pipe-${toM.toFixed(2)}`; + const book = bookOf(projectId); + const moving = nearestWithin(book.ids, fromM, (entry) => entry.at); + if (!moving) return pipeIdAt(toM, projectId); + const stale = nearestWithin( + book.ids.filter((entry) => entry !== moving), + toM, + (entry) => entry.at, + ); + const ids = book.ids + .filter((entry) => entry !== stale) + .map((entry) => (entry === moving ? { ...entry, at: toM } : entry)); + writeState("pipe-ids", { next: book.next, ids }, projectId); + return moving.id; +} + +/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */ +export function withPipeIds( + pipes: ReadonlyArray, + projectId: string | null = active, +): Array { + return pipes.map((pipe) => ({ ...pipe, id: pipeIdAt(pipe.chainage_m, projectId) })); +} + +/** 관을 옮김 — 관 세션 id 가 새 자리로(323-5 · id 그대로) · 그 관을 골라 둔 고름이면 자리만 새로(부재키 + * 그대로 · 옛 자리 이름 id 면 세션 id 로). 다른 것을 골랐으면 그대로. */ +export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void { + const id = movePipeId(fromM, toM, projectId); + const picked = readSelection(projectId); + if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return; + const sid = picked.structureId; + if (sid !== null && !sid.startsWith("pipe-")) return; + selectDesign( + projectId, + { chainageM: toM, structureId: sid === null ? null : id }, + { partKey: picked.partKey }, + ); +} diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 827ffbfe9..e57219467 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -57,6 +57,13 @@ export interface DesignSaveContext { failed: (error: unknown, confirm: boolean) => void; } +const saving = new Set(); + +/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */ +export function savingDesign(projectId: string | null): boolean { + return projectId !== null && saving.has(projectId); +} + /** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */ function reportOnly(label: string): (error: unknown) => void { return (error) => { @@ -130,6 +137,7 @@ export async function saveDesign( const routeId = ctx.routeId(); if (routeId === null) return false; showLoadingOverlay(); + saving.add(ctx.projectId); try { await ctx.prepare?.(); for (const step of DESIGN_SAVE_ORDER) { @@ -150,12 +158,14 @@ export async function saveDesign( await (label ? run().catch(reportOnly(label)) : run()); } clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32) + saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림 await ctx.done?.(confirm); return true; } catch (error) { ctx.failed(error, confirm); return false; } finally { + saving.delete(ctx.projectId); hideLoadingOverlay(); } } diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 10d353761..20014b367 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -22,6 +22,8 @@ import { linkOf, readState, solveSplit, + specContainerHidden, + specItemHidden, TEXT_MAX, writeOptions, type SpecContainer, @@ -82,6 +84,8 @@ export interface SpecForm { groupNames?: Record, /** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */ defaults?: Record, + /** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */ + hidden?: ReadonlySet, ) => void; read: () => Record; state: () => SpecState; @@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { let names: SpecNames = {}; let groupNames: Record = {}; let defaults: Record = {}; + let hidden: ReadonlySet = new Set(); const recent = new Map(); const slots = new Map(); const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = []; @@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { } const fields = c.items .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) + .filter((item) => !specItemHidden(item, hidden)) .map((item) => { const input = control(c, item, view, tags.get(item.id)); const wrap = labeled(itemLabel(c, item, names), input); @@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const fitted: SpecContainer[] = []; for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); + if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2) if (fit(c, list)) fitted.push(c); shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index))); if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; @@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return { root, - write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) { + write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) { spec = next; defaults = nextDefaults; + hidden = nextHidden ?? new Set(); names = nextNames; groupNames = nextGroups; state = readState(next, options); diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index 9b1e7b79e..9f1371201 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box"; import { FORD_SPEC } from "./design_structure_ford"; import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement"; import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road"; +import { baseType } from "../../common_util/common_util_structure_catalog"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly> = { pavement_concrete: PAVEMENT_SPEC, etc: ETC_SPEC, }; + +/** 그 종류의 명세 — 복사한 종류는 본보기 명세(323-7 · `baseType`). 화면은 이것으로 고름(종류 키 직접 안 씀). */ +export const specFor = (typeId: string): StructureSpec | undefined => + STRUCTURE_SPECS[baseType(typeId)]; diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 3ba11d1d1..4e6ff6549 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string => const text = (value: unknown): string => value === undefined || value === null ? "" : String(value); +/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */ +export const specItemKeys = (item: SpecItem): string[] => + [item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter( + (key): key is string => !!key, + ); + +/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */ +export const specItemHidden = (item: SpecItem, hidden: ReadonlySet): boolean => { + const keys = specItemKeys(item); + return keys.length > 0 && keys.every((key) => hidden.has(key)); +}; + +/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */ +export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet): boolean => + c.items.some((item) => specItemKeys(item).length > 0) && + c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden)); + /** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */ export function readState( spec: StructureSpec, diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts new file mode 100644 index 000000000..79203c190 --- /dev/null +++ b/A00_Common/design/design_structure_status.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * A00_Common/design/design_structure_status.ts + * 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션) + * → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) → + * 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴). + * + * 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함). + * ========================================================================== */ + +import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import type { PlannedStation } from "../../common_util/common_util_structure_station"; +import { useDesignStore, type DesignStoreValues } from "./design_store"; + +/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */ +export function structureStatus( + saved: readonly [ReadonlyArray

, ReadonlyArray], + drafts: readonly [ReadonlyArray

| null, ReadonlyArray | null], +): { pipes: P[]; structures: S[] } { + return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] }; +} + +/** + * 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` · + * 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 = + * 「배관」 꼴(관 끌기 · 지우기 길). + */ +export function plannedIrregular( + planned: ReadonlyArray, + intervalM: number, +): IrregularStation[] { + const interval = intervalM > 0 ? intervalM : 20; + return planned.map((station) => { + const owner = station.owners[0]; + const pipe = owner.startsWith("pipe-") && station.role === "anchor"; + const number = Math.floor(station.chainage_m / interval); + return { + id: station.role === "anchor" ? owner : `${owner}:${station.role}`, + station: number, + remainder: station.chainage_m - number * interval, + chainage_m: station.chainage_m, + structure: station.title, + ...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}), + }; + }); +} + +/** + * 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 · + * 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴). + * 돌려주는 함수로 끊음. + */ +export function watchDrafts(projectId: string | null, onChange: () => void): () => void { + const shop = useDesignStore(projectId, null); + type Pipe = NonNullable[number]; + const layout = (pipes: ReadonlyArray | null): string => + JSON.stringify( + (pipes ?? []).map((p) => [ + p.chainage_m, + p.facility ?? "pipe", + p.start_m ?? null, + p.end_m ?? null, + p.summary_item ?? null, + p.group_items ?? null, + ]), + ); + let seen = layout(shop.get("pipes")); + const offs = [ + shop.subscribe("structures", onChange), + shop.subscribe("pipes", (pipes) => { + const key = layout(pipes); + if (key === seen) return; + seen = key; + onChange(); + }), + ]; + return () => offs.forEach((off) => off()); +} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 2c603a687..8b7cee4fd 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -17,10 +17,8 @@ import { leaveForDashboard } from "../b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations"; -import type { - StructureInstance, - StructureType, -} from "../../B05_Profile/B05_Profile_Api_Structures"; +import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { pipeIdAt } from "./design_pipe_ids"; import { fetchSectionContext, getSections, @@ -39,14 +37,14 @@ import { import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { paintMapPick } from "./design_pick_profile"; -import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { watchDrafts } from "./design_structure_status"; +import { savingDesign } from "./design_save"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, - replayDraftPipeOptions, type PipeOptionsContext, } from "../../B06_Section/B06_Section_UI_Page_Pipe_Options"; import { @@ -134,8 +132,7 @@ export async function mountCrossView( ); // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). - let structureMarksSink: - ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; + let structureMarksSink: Parameters[0]["onMarks"] | null = null; /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ let pipeReloadTimer = 0; let wallAdjustTimer = 0; @@ -143,12 +140,11 @@ export async function mountCrossView( projectId, shared, // 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4). - onMarks: (structures, types) => { - structureMarksSink?.(structures, types); + onMarks: (structures, types, pipes) => { + structureMarksSink?.(structures, types, pipes); syncBermSpans(structures); }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). - onStructuresChanged: () => void refreshDetailForStructures(), onPipesWritten: () => { window.clearTimeout(pipeReloadTimer); pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200); @@ -223,6 +219,7 @@ export async function mountCrossView( async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) + if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9) try { const fresh = await loadSectionDetail(projectId, currentRouteId); if (seq !== structuresRefreshSeq) return; @@ -337,7 +334,7 @@ export async function mountCrossView( shared, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). - structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); + structureMarksSink = sectionView.setStructureMarks; // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 @@ -403,7 +400,7 @@ export async function mountCrossView( // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) const pipeAt = structuresPanel.currentChainage(); - const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + const mark = opened.structureId ?? (pipeAt === null ? null : pipeIdAt(pipeAt, projectId)); sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5) structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 @@ -540,6 +537,7 @@ export async function mountCrossView( // 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로 // 실려 나가던 자리(2026-09-09 실측). reconcileDesigns: () => reconcileStaleDesigns({ force: true }), + redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()), patchSources: () => ({ rockOffsets: rockStore.offsets, cutSlopeRatios: cutSlopeStore.ratios, @@ -567,6 +565,11 @@ export async function mountCrossView( void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); + // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) + const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick); + host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel())); + // 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음) + host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); window.clearTimeout(wallAdjustTimer); @@ -666,8 +669,6 @@ export async function mountCrossView( bindPipeOptions(shared, (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), ); - // 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a). - replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId)); renderSectionDetail(); // 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다 // (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만. diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index be81dae68..3bb3c6acb 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -41,7 +41,6 @@ import { type DesignPageShared, } from "./design_shared"; import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life"; -import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures"; import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import { @@ -57,7 +56,11 @@ import { type SectionDetailResponse, } from "../../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; -import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { + migrateLegacyStations, + onStaleStructureTypes, + structureAnchorM, +} from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { loadCorridorIfFresh, @@ -77,6 +80,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; 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"; +import { pipeIdAt } from "./design_pipe_ids"; import { DEFAULT_ROAD_WIDTHS, fetchRoadWidths, @@ -131,10 +135,8 @@ export async function mountProfileView( const structurePick = !!readSelection(activeProjectId)?.structureId; const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []); const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections); - if ("pipeChainageM" in target) { - profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`); - return; - } + if ("pipeChainageM" in target) + return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId)); profilePanel.setSelectedStation(target.stationId); if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId); }; @@ -548,6 +550,9 @@ export async function mountProfileView( onReset: () => uphillOverrides.clear(), }); + // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) + const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load()); + host.onDispose(onStaleStructureTypes(reloadIfShown)); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index f94f563b5..f8078b5a4 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -471,17 +471,10 @@ export async function fetchRoadInflow( export type PipeSource = "stream" | "spacing" | "user"; /** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에 - * 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */ + * 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 · + * 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */ export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment"; -export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [ - ["pipe", "배수관"], - ["box_culvert", "BOX암거"], - ["ford_pavement", "물넘이포장"], - ["ford_bridge", "세월교"], - ["revetment", "기슭막이"], -]; - export interface DetailPipePoint { chainage_m: number; lonlat: [number, number]; diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 52b2fc400..0a89149af 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -11,9 +11,11 @@ * 하려는 것이라, 목록은 반드시 서버에서 받아 온다. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; +import { useCatalogDefinition } from "../common_util/common_util_structure_catalog"; +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { useDesignStore } from "../A00_Common/design/design_store"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { type DefinedRule, @@ -77,7 +79,6 @@ export interface StructureType { placement: StructurePlacement; options: StructureOptionField[]; style: { color?: string; abbr?: string }; - drawing_views: string[]; /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ managed_by: string | null; /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. @@ -140,6 +141,8 @@ export interface StructureListResponse { project_id: string; revision: number; structures: StructureInstance[]; + /** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */ + definition_version?: number | null; } export interface StructureSaveResponse { @@ -192,8 +195,66 @@ async function requestJson(path: string, init: RequestInit = {}): Promise } } -/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */ +/** 타입 레지스트리 캐시 — 정의 판(`typesVersion`)이 바뀌면(M02 저장) 버리고 다시 받음(322-0). */ let typesCache: Promise | null = null; +let typesVersion: number | null = null; + +/** 정의 판이 캐시 판과 다르면 종류 캐시를 버림 — 버렸으면 true(B05 · B06 보기를 열 때 · 탭이 다시 보일 때). */ +export async function dropStaleStructureTypes(): Promise { + if (!typesCache || typesVersion === null) return false; + const now = await requestJson<{ 판: number }>("/m02/structure-definition/version", { + method: "GET", + }) + .then((payload) => payload.판) + .catch(() => null); + if (now === null || now === typesVersion) return false; + typesCache = null; + typesVersion = null; + return true; +} + +const noticed = new Set(); +const staleSaved = new Set(); // 정본이 옛 정의 판으로 적힌 프로젝트(323-9) + +/** 정본이 옛 정의 판으로 적혔나 — 그러면 고침이 없어도 [저장]이 정본을 다시 써 지금 판 도장(323-9). */ +export function definitionStale(projectId: string): boolean { + return staleSaved.has(projectId); +} + +/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */ +export function staleDefinitionNotice( + projectId: string, + saved: number | null | undefined, +): string | null { + if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) { + staleSaved.delete(projectId); + return null; + } + staleSaved.add(projectId); + const key = `${projectId}:${saved}:${typesVersion}`; + if (noticed.has(key)) return null; + noticed.add(key); + return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`; +} + +const staleListeners = new Set<() => void>(); + +function checkStaleOnShow(): void { + if (document.visibilityState !== "visible") return; + void dropStaleStructureTypes().then((stale) => { + if (stale) staleListeners.forEach((reload) => reload()); + }); +} + +/** 탭이 다시 보일 때 정의 판이 바뀌었으면 `reload`(종류 · 패널 다시 읽음) — 떼는 함수를 돌려줌. */ +export function onStaleStructureTypes(reload: () => void): () => void { + if (!staleListeners.size) document.addEventListener("visibilitychange", checkStaleOnShow); + staleListeners.add(reload); + return () => { + staleListeners.delete(reload); + if (!staleListeners.size) document.removeEventListener("visibilitychange", checkStaleOnShow); + }; +} /** * 구조물 타입 목록. @@ -212,7 +273,12 @@ export function fetchStructureTypes(includeDisabled = false): Promise("/projects/structure-types", { method: "GET", }) - .then(async (payload) => withDefinition(payload.types, await definition)) + .then(async (payload) => { + const defined = await definition; + typesVersion = defined?.판 ?? null; + useCatalogDefinition({ types: payload.types, ...defined }); // 구조물 표(322-1 · 반영 · 부품) + return withDefinition(payload.types, defined); + }) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; @@ -239,6 +305,7 @@ export async function saveStructures( { method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) }, ); noteSnapshotFailed(saved.snapshot_failed); + staleSaved.delete(projectId); // 서버가 지금 판으로 적음 return saved; } @@ -267,7 +334,7 @@ export function pipesToStructureMarks( }>, ): StructureInstance[] { return pipes.map((pipe) => ({ - structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5) type_id: pipe.facility, placement: "point", chainage_m: pipe.chainage_m, @@ -304,21 +371,22 @@ export function defaultOptions(type: StructureType): Record("structures", projectId); + return useDesignStore(projectId, null).get("structures"); } +/** 설계 가게로 씀 — 관 초안과 같은 바뀜 신호(구독 · 323-6). 칸은 그대로(`structures`). */ export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void { - writeState("structures", next, projectId); + useDesignStore(projectId, null).set("structures", next); } /** * 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는 - * 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다. + * 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9). */ export async function flushPendingStructures(projectId: string): Promise { const pending = readPendingStructures(projectId); - if (!pending) return; + if (!pending && !definitionStale(projectId)) return; const stored = await fetchStructures(projectId); - await saveStructures(projectId, stored.revision, pending); + await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만 writePendingStructures(projectId, null); } diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 7f8987fae..b9e354689 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -25,12 +25,9 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import ( ) from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import ( - is_station_planting_type, structure_type_map, ) from common_util.common_util_drainage_pipes import ( - PIPE_FACILITY_PIPE, - PIPE_FACILITY_REVET, PipePoint, load_pipe_points_file, parse_pipe_points, @@ -43,14 +40,14 @@ from common_util.common_util_route_geometry import ( planned_route_initial_path, planned_route_working_path, ) +from common_util.common_util_structure_catalog import card_title, catalog +from common_util.common_util_structure_station import plan_stations from common_util.common_util_surface_sampler import build_surface_sampler from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_EDITS_DIRNAME, DRAINAGE_PIPE_POINTS_FILENAME, FOREST_ROAD_PROFILE_CRITERIA, - PIPE_DEFAULT_DIAMETER_MM, - PIPE_DEFAULT_TYPE, ) logger = logging.getLogger(__name__) @@ -207,14 +204,10 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: - """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름.""" - if pipe.facility == PIPE_FACILITY_PIPE: - values = pipe.options or {} - kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) - diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) - return f"{kind} D{diameter}" - definition = types.get(pipe.facility) - return definition.name if definition is not None else None + """관 지점 측점 라벨 = 표 카드 이름(322-1 · 배관 = 「관종 D관경」 · 빈칸 = 정의 기본값).""" + if pipe.facility not in types: + return None + return card_title(pipe.facility, pipe.options) def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None: @@ -236,7 +229,7 @@ def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any except (OSError, ValueError, AttributeError): return types = structure_type_map() - pipe_names = {definition.name for definition in types.values() if definition.managed_by} + pipe_names = {row["name"] for row in catalog().values() if row["owner"] == "pipe_points"} for section in cross_sections: label = section.get("structure") if not isinstance(label, str) or not ( @@ -266,50 +259,20 @@ def resolve_extra_stations( 물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1). - 라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를 - 되짚기 때문이다(B05_Profile_Structures_Migration). - TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) · - 거울 시험 = `resources/tester/test_272_1_station_list.py`. + 측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도 + (anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이 + 먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다. + 거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`. """ - types = structure_type_map() - derived: list[tuple[float, str]] = [] - for pipe in pipes: - label = pipe_station_label(pipe, types) - if label is None: - continue - # 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 — - # 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은 - # 기준 한 곳(관이 노선을 가로지르는 점형). - if pipe.facility == PIPE_FACILITY_REVET: - marks = (pipe.start_m, pipe.chainage_m, pipe.end_m) - else: - marks = (pipe.chainage_m,) - for mark in marks: - if mark is not None: - derived.append((float(mark), label)) - # 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은 - # structures.json에 저장되지 않아 중복되지 않는다. - # · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳. - # · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장 - # 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가 - # 격자로 스냅해 파일이 겹치지 않는다. try: - for structure in load_structures(str(project_root))[1]: - definition = types.get(structure.type_id) - if definition is None or definition.managed_by: - continue - if not is_station_planting_type(definition): - continue - if definition.placement == "interval": - marks = (structure.start_m, structure.chainage_m, structure.end_m) - else: - marks = (structure.anchor_m(),) - for mark in marks: - if mark is not None: - derived.append((float(mark), definition.name)) + structures = load_structures(str(project_root))[1] except Exception: logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)") - return tuple(sorted(derived)) + structures = [] + return tuple( + (float(station["chainage_m"]), str(station["title"])) + for station in plan_stations(pipes, structures) + ) def _append_design_profiles( diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 27345f602..b4703ddc2 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -16,15 +16,16 @@ """ import re +from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, - station_planting_labels, structure_type_map, ) from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint +from common_util.common_util_structure_station import SAME_STATION_M PIPE_STRUCTURE_NAME = "배관" # 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`). @@ -54,28 +55,22 @@ _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE) _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) -def _managed_elsewhere_labels() -> set[str]: - """서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 → - "기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels). +def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool: + """정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령. - 관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서 - 투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관). + 자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기 + (지난 판 이름 전부 · 322-0)는 걷음 · 이름을 바꿔도 자리는 그대로라 옛 이름 측점도 알아봄. + 지금 판 종류 이름 · 관 라벨 꼴(관종 D관경)은 자리를 못 받은 부름(옛 확정분만)을 위해 그대로. """ - owned = { - item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER - } - return station_planting_labels() | owned - - -def _is_managed_elsewhere(entry: dict[str, Any]) -> bool: - """다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가.""" label = str(entry.get("structure", "")).strip() + at = float(entry.get("chainage_m", 0.0)) return ( entry.get("origin") == "pipe" or entry.get("structureType") == PIPE_STRUCTURE_NAME or label == PIPE_STRUCTURE_NAME - or label in _managed_elsewhere_labels() + or label in {item.name for item in structure_type_map().values()} or bool(_PIPE_LABEL.match(label)) + or any(abs(at - p) < SAME_STATION_M for p in planted) ) @@ -107,7 +102,9 @@ def _escape_width_of(entry: dict[str, Any]) -> float | None: return None -def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPlan: +def migrate_irregular_stations( + entries: Iterable[dict[str, Any]], planted: Sequence[float] = () +) -> MigrationPlan: """기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과). 타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 — @@ -120,7 +117,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPl seen: set[tuple[str, float]] = set() for entry in entries: - if _is_managed_elsewhere(entry): + if _is_managed_elsewhere(entry, planted): continue chainage = float(entry.get("chainage_m", 0.0)) diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index bc2ad31ed..7629a36a4 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -69,6 +69,17 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]: return revision, structures +def load_saved_definition_version(project_root: str) -> int | None: + """정본을 적을 때의 구조물 정의 판(322-2 판 알림) — 파일이 없거나 옛 파일이면 None.""" + path = structures_file_path(project_root) + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle).get("definition_version") + except (OSError, ValueError, TypeError, AttributeError): + return None + return value if isinstance(value, int) and not isinstance(value, bool) else None + + def load_migrated_legacy(project_root: str) -> set[str]: """이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합. diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 4a0912699..a21bab4a4 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stati from B05_Profile.B05_Profile_Structures_Repository import ( StructureRevisionConflict, load_migrated_legacy, + load_saved_definition_version, load_structures, requires_downstream_invalidation, save_structures, @@ -42,6 +43,7 @@ from common_util.common_util_drainage_pipes import ( read_pipe_points_file, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_structure_station import plan_stations from config.config_db import get_db_pool from M02_MasterTemplete.StructOptions.M02_SO_Options import TypesWithVisible, with_visible @@ -159,7 +161,10 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) revision, structures = load_structures(root) return StructureListResponse( - project_id=str(project_id), revision=revision, structures=structures + project_id=str(project_id), + revision=revision, + structures=structures, + definition_version=load_saved_definition_version(root), ) except LookupError: # 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다. @@ -189,7 +194,14 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) revision, existing = load_structures(root) migrated_before = load_migrated_legacy(root) - plan = migrate_irregular_stations(payload.stations) + # 정본이 이미 심는 자리(측점 계획 · 323-7) — 이름이 아니라 자리로 가름(옛 이름 되짚기 걷음) + planted = [ + float(station["chainage_m"]) + for station in plan_stations( + read_pipe_points_file(pipe_points_path_in(Path(root))), existing + ) + ] + plan = migrate_irregular_stations(payload.stations, planted) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, # 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다 # (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본). diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 64264b26d..ec6a6d0f2 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -88,8 +88,6 @@ class StructureType(BaseModel): placement: Placement options: list[StructureOptionField] = Field(default_factory=list) style: dict[str, str] = Field(default_factory=dict) - # B06 도면 연계용 메타데이터 — 저장만 하고 이번 단계 완료 조건에는 넣지 않는다. - drawing_views: list[str] = Field(default_factory=list) # 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다. managed_by: str | None = None # 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다 @@ -116,10 +114,19 @@ def _read_definition(path: str, _mtime: int, _size: int) -> dict[str, Any]: return json.load(handle) +#: 정의에 남은 옛 칸 — 아무도 안 읽어 걷음(323-7 · 마스터는 M02 [저장] 때 빠짐 · 읽을 때 버림). +RETIRED_TYPE_KEYS = ("drawing_views",) + + +def without_retired(item: dict[str, Any]) -> dict[str, Any]: + """종류 한 줄에서 옛 칸을 뺀 사본.""" + return {k: v for k, v in item.items() if k not in RETIRED_TYPE_KEYS} + + @lru_cache(maxsize=4) def _read_types(path: str, mtime: int, size: int) -> tuple[StructureType, ...]: payload = _read_definition(path, mtime, size) - return tuple(StructureType.model_validate(item) for item in payload["types"]) + return tuple(StructureType.model_validate(without_retired(item)) for item in payload["types"]) def load_definition() -> dict[str, Any]: @@ -141,25 +148,19 @@ def structure_type_map() -> dict[str, StructureType]: def is_station_planting_type(item: StructureType) -> bool: - """서버가 종단 정본에 **측점으로 심는** 타입인가 (단일 규칙, 2026-08-28). + """서버가 종단 정본에 **측점으로 심는** 타입인가 — 구조물 표 횡단도 부품이 있음(322-1). - - A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다. - - D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면 - 그만큼 여러 장이 나온다(사용자 확정). - - C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료. - 안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다. - - 측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단 - (`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다. + anchor(기준 한 장 · 횡단배수 · 관 지점 시설) · span3(시작 · 기준 · 끝 · 벽 · 소단 · 기슭막이 + — 길이가 길면 그만큼 여러 장). 옛 군 글자 규칙(A군 · C · D군 구간형)은 표 처음값으로 옮김. """ - if item.group == "A": - return True - return item.group in ("C", "D") and item.placement == "interval" + from common_util.common_util_structure_catalog import catalog_row + + row = catalog_row(item.type_id) + return row is not None and row["station"] != "none" -def station_planting_labels() -> set[str]: - """서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다.""" - return {item.name for item in load_structure_types() if is_station_planting_type(item)} +#: 지난 판 정의(`판_N.json`) 폴더 — 정본 옆(M02 `M02_SO_Definition.HISTORY` 와 같은 이름) +HISTORY_DIRNAME = "구조물정의_이력" def registry_schema_version() -> int: @@ -290,3 +291,5 @@ class StructureListResponse(BaseModel): project_id: str revision: int structures: list[StructureInstance] = Field(default_factory=list) + # 정본을 적을 때의 정의 판(322-2) — 지금 판과 다르면 화면이 「저장하면 반영」 한 줄 알림 + definition_version: int | None = None diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 5f216cc58..b25371a7f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -382,7 +382,12 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou (build.structures ?? []).forEach((structure, index) => { // 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다 // (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다. - const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key }; + const tag = { + chainageM: structure.chainage_m, + kind: structure.kind, + key: structure.key, + structureId: structure.structure_id, // 323-5 — 벽은 id 로 고름 + }; if (structure.kind === "pipe") { const mesh = structurePipeMesh(structure, origin); if (mesh) { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 2a6b0c3fc..9365f237b 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -20,7 +20,7 @@ * 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다. * ========================================================================== */ -import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash"; +import { structureHashParts, wallSweepAt } from "./B05_Profile_UI_Corridor_Structures_Hash"; import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { @@ -54,6 +54,7 @@ import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Reve import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard"; import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute"; import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; +import { builds, culvertBody, ownsBody } from "./B05_Profile_UI_Corridor_Structures_Pick"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -85,6 +86,8 @@ export interface CorridorStructure { * 신원표다(2026-09-04). 세월교·BOX암거 부재는 아직 비워 둔다. */ key?: string; + /** 구조물 id(323-5) — C군 벽(id 로 고름 · 덮는 측점 어디서 골라도 · 한 번 스윕). 관 부재는 자리로 고름. */ + structure_id?: string; /** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */ polygon?: Array<[number, number]>; /** @@ -276,17 +279,31 @@ export function buildCorridorStructures( ): CorridorStructure[] { const solids: CorridorStructure[] = []; for (const section of crossSections) { - const layout = culvertLayoutOf(section); + // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) · 같은 시설 카드 둘이면 한 번(323-9) + const own = (key: "culvert" | "ford" | "box"): boolean => ownsBody(section, crossSections, key); + const layout = own("culvert") && culvertBody(section) ? culvertLayoutOf(section) : null; // 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1) - const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout); + const fordLayout = + own("ford") && builds("ford_bridge", "ford") + ? withoutGuardedFill(fordLayoutOf(section), layout) + : null; // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). - const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) - ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) + // C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것) + const wallSweep = wallSweepAt(section, crossSections); + const wallId = section.revetment?.structure_id; + const revetLayout = + wallSweep && + ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) && + builds(section.revetment?.type_id, "wall") + ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) + : null; + const paveChute = builds("ford_pavement", "chute") + ? fordPavementChute(section, crossSections) : null; - const paveChute = fordPavementChute(section, crossSections); - if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue; + const box = own("box") && builds("box_culvert", "box") ? section.box : undefined; + if (!layout && !fordLayout && !box && !revetLayout && !paveChute) continue; const chainage = section.chainage_m; const stationFrame: StructureFrame = { cx: section.center_x, @@ -355,18 +372,12 @@ export function buildCorridorStructures( }); }; - if (revetLayout) { + if (revetLayout && wallSweep) { // 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다. - for (const tier of revetLayout.tiers) { - pushSwept( - "revet", - tier.polygon, - revetLayout.span.beforeM, - revetLayout.span.afterM, - null, - "own", - ); - } + const from = solids.length; + for (const tier of revetLayout.tiers) + pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own"); + for (const solid of solids.slice(from)) solid.structure_id = wallId ?? undefined; } /** @@ -447,7 +458,7 @@ export function buildCorridorStructures( } }; - if (section.box) { + if (box) { solids.push(...boxSolids(section, stationFrame, boxAdjustOf(section))); } paveChute?.bands.forEach((band) => diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 6cbfcd72f..464815174 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -1,141 +1,79 @@ /* ============================================================================= * B05_Profile_UI_Corridor_Structures_Hash.ts - * 저장 코리도 만료 판정용 **구조물 해시 요약**. + * 저장 코리도 만료 판정용 **구조물 지문** · C군 벽 3D 한 번(323-4). * - * `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02). - * 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 — - * 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것. - * 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다. + * 지문 = 그 측점 그림 자료(형상 얹기 한 벌 `SHAPE_KEYS` · 카드 · 상세 창 · B07 · 3D 가 같은 목록) + 형상을 + * 바꾸는 조작값 통째를 키 차례 고정 글로 — 손으로 고른 칸 목록(빠뜨린 칸 = 「고쳐도 3D 가 안 바뀜」)을 걷음. + * 원래 파일(`B05_Profile_UI_Corridor_Structures`)이 그대로 다시 내보내므로 기존 import 경로는 유효하다. * ========================================================================== */ -import type { - ChuteSpec, - CrossSection, - CulvertSideSpec, -} from "../B06_Section/B06_Section_Api_Fetch"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { SHAPE_KEYS } from "../common_util/common_util_structure_shape_keys"; -/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */ -function chuteParts(chute: ChuteSpec | null | undefined): Array { - if (!chute) return []; - // 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로 - const base = ["ch", chute.structure, chute.thickness_m]; - return chute.length_m ? [...base, chute.length_m] : base; -} +/** 형상을 바꾸는 조작값(정본 `design` 칸) — 조정창 · 다단 · 연동 · 유입 구조. */ +const ADJUST_KEYS = [ + "revet_adjust", + "box_adjust", + "ford_adjust", + "basin_adjust", + "extra_wall_counts", + "extra_spans", + "revet_follow_grade", + "revet_link_detached", + "inlet_structure", +] as const; -/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ -export function structureHashParts(section: CrossSection): Array { - // 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 — - // 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28). - const extras: Array = []; - const fordPave = section.ford_pavement; - if (fordPave) { - extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? ""); - extras.push(...chuteParts(fordPave.chute)); - } - const ownRevet = section.revetment; - if (ownRevet) { - extras.push( - "rv", - ownRevet.start_m, - ownRevet.end_m, - ownRevet.anchor_m, - ownRevet.height_m ?? "", - ownRevet.side ?? "", - ownRevet.form ?? "", - ownRevet.tiers ?? "", - ownRevet.lift_m ?? "", - ownRevet.shift_m ?? "", - // 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다. - JSON.stringify(section.design?.revet_adjust?.own ?? ""), - ); - } - const box = section.box; - // 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음. - if (box) { - extras.push( - "bx", - box.inner_width_m, - box.inner_height_m, - box.span_m, - box.cover_m, - box.wing_in.slab_extend_m, - box.wing_in.height_m ?? "", - box.wing_out.slab_extend_m, - box.wing_out.height_m ?? "", - // 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다. - ...(["left", "right"] as const).map((side) => { - const value = section.design?.box_adjust?.[side]; - return value ? `${side}:${value.lengthM},${value.riseM}` : ""; - }), - ...chuteParts(box.chute), - ); - } - const ford = section.ford; - if (ford) { - const adjust = section.design?.ford_adjust; - const wall = (role: "inlet" | "outlet"): string => { - const value = adjust?.[role]; - return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : ""; - }; - extras.push(`dr:${adjust?.dropM ?? ""}`); - extras.push( - "fd", - ford.diameter_m, - ford.pipe_count, - ford.span_m, - ford.wing_in.slab_extend_m, - ford.wing_out.slab_extend_m, - wall("inlet"), - wall("outlet"), - ...chuteParts(ford.chute), - ); - } - const culvert = section.culvert; - if (!culvert) return extras; - const side = (spec: CulvertSideSpec): string => - [ - spec.structure, - spec.revet_form ?? "", - spec.revet_height_m ?? "", - spec.revet_length_m ?? "", - spec.revet_before_m ?? "", - spec.revet_after_m ?? "", - spec.basin_length_m ?? "", - spec.basin_before_m ?? "", - spec.basin_after_m ?? "", - spec.basin_height_m ?? "", - ].join(","); - const basin = section.design?.basin_adjust; - const revet = section.design?.revet_adjust; - const counts = section.design?.extra_wall_counts; +/** 키 차례를 고정한 JSON — 같은 값이면 같은 글. */ +const stable = (value: unknown): string => + JSON.stringify(value, (_key, item: unknown) => + item && typeof item === "object" && !Array.isArray(item) + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [key, (item as Record)[key]]), + ) + : item, + ); + +/** 해시 입력 — 구조물이 없는 측점은 빈 목록(저장본 지문 그대로). */ +export function structureHashParts(section: CrossSection): string[] { + const record = section as unknown as Record; + const shapes = SHAPE_KEYS.filter((key) => record[key] != null); + if (!shapes.length) return []; + const design = (section.design ?? {}) as unknown as Record; return [ - ...extras, - "cv", - culvert.diameter_m, - culvert.pipe_kind ?? "", - side(culvert.inlet), - side(culvert.outlet), - section.design?.inlet_structure ?? "", - basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "", - // 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다. - revet - ? Object.keys(revet) - .sort() - .map((role) => { - const value = revet[role]; - return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`; - }) - .join("|") - : "", - counts ? `${counts.outlet},${counts.basin}` : "", - section.design?.revet_follow_grade === false ? "flatZ" : "", - // 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼 - // 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30). - Object.entries(section.design?.extra_spans ?? {}) - .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`) - .join("|"), - section.design?.revet_link_detached === true ? "detached" : "", - ...chuteParts(culvert.chute), + stable({ + ...Object.fromEntries(shapes.map((key) => [key, record[key]])), + adjust: Object.fromEntries(ADJUST_KEYS.map((key) => [key, design[key] ?? null])), + }), ]; } + +/** + * C군 벽 3D 한 번 — 그 벽을 얹은 측점 중 기준 측점과 가장 가까운 것(목록 앞쪽 먼저)만 스윕 · + * 스윕 = 시작 ~ 끝(그 측점 기준 전 · 후). 다른 측점은 null(덮는 측점마다 통째로 겹치던 것). + */ +export function wallSweepAt( + section: CrossSection, + sections: readonly CrossSection[], +): { beforeM: number; afterM: number } | null { + const wall = section.revetment; + if (!wall) return null; + const same = (other: CrossSection): boolean => + !!other.revetment && + (wall.structure_id + ? other.revetment.structure_id === wall.structure_id + : other.revetment.start_m === wall.start_m && other.revetment.end_m === wall.end_m); + const gap = (other: CrossSection): number => Math.abs(other.chainage_m - wall.anchor_m); + const owner = sections + .filter(same) + .reduce( + (best, other) => (!best || gap(other) < gap(best) ? other : best), + null, + ); + if (owner !== section) return null; + return { + beforeM: Math.max(section.chainage_m - wall.start_m, 0), + afterM: Math.max(wall.end_m - section.chainage_m, 0), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts new file mode 100644 index 000000000..52b6b96cd --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts @@ -0,0 +1,38 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Structures_Pick.ts + * 3D 몸체 고르기(322-1) — 그림 자료 열쇠(`culvert` · `box` · `ford` · `ford_pavement` · `revetment`)의 주인 + * 종류가 구조물 표 3D 부품으로 그 몸체를 세우는가. 지금 처음값 = 지금 결과 그대로(표가 끈 것만 빠짐). + * 쓰는 곳 = `B05_Profile_UI_Corridor_Structures.buildCorridorStructures`. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; + +/** 그림 자료의 주인 종류가 이 3D 몸체를 세우는가 — 표에 없는 종류면 세움. */ +export const builds = (typeId: string | null | undefined, body: string): boolean => + (catalogRow(typeId ?? "")?.model3d ?? body) === body; + +/** + * 이 카드가 시설(관 · 세월교 · BOX) 몸체를 세우는가 — 같은 시설(얹을 때 적은 시설 누가거리)을 그리는 카드 중 + * 시설 자리에 가장 가까운 하나만(노선 끝 EP 카드 0.01 m 차 · 323-9 · `fordPavementChute` 와 같은 규칙). + */ +export function ownsBody( + section: CrossSection, + sections: readonly CrossSection[], + key: "culvert" | "ford" | "box", +): boolean { + const at = section[key]?.chainage_m; + if (typeof at !== "number") return true; // 시설 자리 없음 = 옛 자료 그대로 + const gap = (item: CrossSection): number => Math.abs(item.chainage_m - at); + const same = sections.filter((item) => item[key]?.chainage_m === at); + const owner = same.reduce((best, item) => (gap(item) < gap(best) ? item : best), same[0]); + return owner === section; +} + +/** `culvert` 자리 주인 = 박스 · 세월교 흙막이(body) · 독립 기슭막이(관 숨김) · 배관. */ +export function culvertBody(section: CrossSection): boolean { + const spec = section.culvert; + if (!spec) return false; + if (spec.body) return builds(spec.body === "box" ? "box_culvert" : "ford_bridge", spec.body); + return spec.hidden_pipe ? builds("revetment", "wall") : builds("pipe", "pipe"); +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 7a1b435ea..253be24d5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -50,13 +50,14 @@ import { outletTiersRow, revetChoiceRow, } from "./B05_Profile_UI_Drainage_Revet_Choice"; -import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; +import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { createSpecForm, type SpecForm, type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; import { BOX_SPEC, bodyOf, @@ -496,13 +497,16 @@ export function createFacilityOptionsForm( ); let current: PipeFacility | null = null; + /** 서브폼 = 구조물 표 형상(323-7 · 종류 키 대신 · 관 지점 시설은 형상마다 손코딩 폼 하나). */ + const shapeOf = (facility: PipeFacility | null) => + (facility && catalogRow(facility)?.shape) || null; function syncVisibility(): void { root.hidden = current === null; if (current === null) return; - const isPipe = current === "pipe"; - const isBox = current === "box_culvert"; - const isBridge = current === "ford_bridge"; + const isPipe = shapeOf(current) === "pipe_set"; + const isBox = shapeOf(current) === "box"; + const isBridge = shapeOf(current) === "ford"; pipeForm.root.hidden = !isPipe; boxForm.root.hidden = !isBox; fordForm.root.hidden = !isBridge; @@ -512,8 +516,8 @@ export function createFacilityOptionsForm( wingOutFields.root.hidden = true; (spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot); (spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot); - paveForm.root.hidden = current !== "ford_pavement"; - const isRevet = current === "revetment"; + paveForm.root.hidden = shapeOf(current) !== "ford_pavement"; + const isRevet = shapeOf(current) === "hidden_revet"; revetTopRow.hidden = !isRevet; // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). const side = revetSide.value || "양쪽"; @@ -580,10 +584,10 @@ export function createFacilityOptionsForm( }, setChainage(chainageM) { chainage = chainageM; - if (current === "pipe") pipeForm.refresh(); + if (shapeOf(current) === "pipe_set") pipeForm.refresh(); }, recount() { - if (current === "pipe") pipeForm.recount(); + if (shapeOf(current) === "pipe_set") pipeForm.recount(); }, onInletStructureChange(handler) { inletStructureHandler = handler; @@ -607,6 +611,7 @@ export function createFacilityOptionsForm( const names = definition?.spec_names ?? {}; // 필수 상자 빈 고르기 칸이 받는 정의 기본값(54-43) — 옛 키는 열 때와 같은 길로 바꿈 const defaults = optionDefaults(definition?.options ?? []); + const hidden = hiddenOptionKeys(definition?.options ?? []); // 정의 「표시」 숨김(322-2) if (facility === "pipe") { // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) const { soil_add: _old, ...pipeNames } = names; @@ -616,11 +621,18 @@ export function createFacilityOptionsForm( !["revet", "I", "L", "U"].includes(String(kind ?? "")) ? { structure: kind } : null; - pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults); + pipeForm.write(PIPE_SPEC, options, pipeNames, groups, defaults, hidden); } if (facility === "box_culvert") { // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 - boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups, boxOptionsIn(defaults)); + boxForm.write( + BOX_SPEC, + boxOptionsIn(options), + names, + groups, + boxOptionsIn(defaults), + hidden, + ); for (const [id, value] of Object.entries(bodyOf(options))) boxForm.setValue("body", id, value); } @@ -631,9 +643,10 @@ export function createFacilityOptionsForm( names, groups, withLegacyGuards(defaults), + hidden, ); if (facility === "ford_pavement") - paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults); + paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults, hidden); if (facility === "ford_bridge") fordFlowBox.fill(); if (facility === "ford_pavement") paveFlowBox.fill(); wingInFields.write(options); @@ -649,28 +662,28 @@ export function createFacilityOptionsForm( }, setDesignFlow(designFlow) { fordFlow = designFlow ?? null; - if (current === "ford_bridge") fordFlowBox.fill(); - if (current === "ford_pavement") paveFlowBox.fill(); + if (shapeOf(current) === "ford") fordFlowBox.fill(); + if (shapeOf(current) === "ford_pavement") paveFlowBox.fill(); syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). - if (current === "pipe") { + if (shapeOf(current) === "pipe_set") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) // 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13) if (inletIsAuto()) { if (inletAuto!.structure === undefined) delete options.inlet_structure; else options.inlet_structure = inletAuto!.structure; } - } else if (current === "box_culvert") { + } else if (shapeOf(current) === "box") { // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); - } else if (current === "ford_pavement") { + } else if (shapeOf(current) === "ford_pavement") { Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3) - } else if (current === "ford_bridge") { + } else if (shapeOf(current) === "ford") { Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) - } else if (current === "revetment") { + } else if (shapeOf(current) === "hidden_revet") { if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 // 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제). diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 74f3c0574..d8d32dcd5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,7 +30,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows"; 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 { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids"; import { snapStructureStation } from "../common_util/common_util_structure_station"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; @@ -584,6 +584,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (index < 0) return; const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게) facilityStore.move(pipeEditor.pipes()[index].chainage_m, to); + followMovedPipe(projectId, pipeEditor.pipes()[index].chainage_m, to); // 고름 유지(322-3) pipeEditor.moveTo(index, to); }, selectBasinByChainage(chainageM) { @@ -637,6 +638,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const index = pipeEditor .pipes() .findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M); + followMovedPipe(projectId, fromChainageM, toChainageM); // 고름 유지(322-3) if (index >= 0) pipeEditor.moveTo(index, toChainageM); else void analyze(); } else { diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index a7d00fefa..cf126dcc4 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -16,7 +16,6 @@ import type { RoutePointKind, } from "./B05_Profile_UI_Markers"; import type { CirclePoint, RouteLatestResponse, RoutePoint } from "./B05_Profile_Api_Fetch"; -import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; import { irregularLabel, @@ -226,15 +225,6 @@ export function interpolateIrregularStations( }); } -/** 시설 종류별 표시 이름 — 그래프·3D 라벨용(배관은 관종·관경까지 따로 붙인다). */ -export const FACILITY_NAMES: Record = { - pipe: "배수관", - box_culvert: "BOX암거", - ford_pavement: "물넘이포장", - ford_bridge: "세월교", - revetment: "기슭막이", -}; - /* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 4b8f39300..0009778ff 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -11,36 +11,32 @@ * 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다. * ========================================================================== */ -import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchStructures, + dropStaleStructureTypes, fetchStructureTypes, + staleDefinitionNotice, + definitionStale, saveStructures, readPendingStructures, writePendingStructures, StructureConflictError, type StructureInstance, + type StructureType, } from "./B05_Profile_Api_Structures"; -import { - isPipeStation, - structureLabel, - type IrregularStation, -} from "./B05_Profile_UI_IrregularStations"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; -import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers"; import { followedStation } from "./B05_Profile_UI_Profile_Alignment"; import { followSnapped } from "./B05_Profile_UI_Structures_Snap"; import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; -import { - pipeStationMarks, - SAME_STATION_M, - structureStationMarks, - type StationMark, -} from "../common_util/common_util_structure_station"; +import { listPick } from "../common_util/common_util_structure_catalog"; +import { planStations } from "../common_util/common_util_structure_station"; +import { plannedIrregular } from "../A00_Common/design/design_structure_status"; +import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -65,56 +61,12 @@ export interface StructuresBridgeDeps { } export function createStructuresBridge(deps: StructuresBridgeDeps) { - /** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */ + /** 비정규 측점(그래프 세로선·3D 라벨) — 측점 계획 한 벌(관 · 측점 심는 구조물 · 322-3). */ let irregularStations: IrregularStation[] = []; - /** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */ - function stationAt( - chainage: number, - ): Pick { - const interval = deps.panel().values().stationInterval || 20; - const station = Math.floor(chainage / interval); - return { station, remainder: chainage - station * interval, chainage_m: chainage }; - } - - /** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서 - * 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */ - const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] => - marks - .filter((mark) => mark.role !== "anchor") - .map((mark) => ({ - id: `${id}:${mark.role}`, - ...stationAt(mark.chainage_m), - structure: label, - })); - - function pipesToStations(pipes: ReadonlyArray): IrregularStation[] { - return pipes.flatMap((pipe) => { - const label = - pipe.facility === "pipe" - ? structureLabel({ - structureType: "배관", - pipeType: PIPE_DEFAULT_TYPE, - diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm), - }) - : FACILITY_NAMES[pipe.facility]; - const id = `pipe-${pipe.chainage_m.toFixed(2)}`; - return [ - { - id, - ...stationAt(pipe.chainage_m), - structure: label, - structureType: "배관" as const, - origin: "pipe" as const, - }, - ...spanStations(id, label, pipeStationMarks(pipe)), - ]; - }); - } - /** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */ function clearProjectedStations(): void { - pipeStations = []; + planPipes = []; irregularStations = []; deps.renderStationLines(); deps.profilePanel().setIrregularStations([]); @@ -139,11 +91,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { irregularStations = stations; deps.renderStationLines(); deps.profilePanel().setIrregularStations(stations); - // 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다. - // 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다. - deps - .profilePanel() - .drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m)); + // 배수유역도로 되먹이지 않음(322-3) — 관 고침 · 지움은 폼 콜백이 배수 패널로 바로 감(관 정본이 원천) } /* ── 구조물 정본(structures.json) ──────────────────────────────────── @@ -159,41 +107,22 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { * 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅 * 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거· * 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */ - let pipeStations: IrregularStation[] = []; + /** 측점 계획 입력 — 관(배수 패널이 알려 줌) · 종류 표(정의 기본값 · 카드 이름). */ + let planPipes: PipeProjection[] = []; + let planTypes: StructureType[] = []; let structureTypeMap = new Map< string, { type_id: string; group: string; name: string; placement: string; managed_by: string | null } >(); - /** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations` - * 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */ - function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] { - return structures.flatMap((structure) => { - const type = structureTypeMap.get(structure.type_id); - const marks = structureStationMarks(structure, type); - const anchor = marks.find((mark) => mark.role === "anchor"); - if (!anchor) return []; - const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`; - const label = type?.name ?? structure.type_id; - return [ - { id, ...stationAt(anchor.chainage_m), structure: label }, - ...spanStations(id, label, marks), - ]; - }); - } - - /** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이 - * 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을 - * 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */ + /** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저). + * 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */ function syncCrossDrainStations(): void { - const kept: IrregularStation[] = []; - for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) { - const taken = - station.origin !== "pipe" && - kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M); - if (!taken) kept.push(station); - } - applyIrregularStations(kept); + if (!planTypes.length) return; + const interval = deps.panel().values().stationInterval || 20; + applyIrregularStations( + plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval), + ); } /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). @@ -211,9 +140,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */ function pipeMarkChainage(structureId: string | null): number | null { - if (!structureId?.startsWith("pipe-")) return null; - const value = Number(structureId.slice("pipe-".length)); - return Number.isFinite(value) ? value : null; + return structureId ? pipeChainageOf(structureId) : null; // 관 세션 id 장부(323-5) } /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. @@ -234,7 +161,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { }>, ): StructureInstance[] { return pipes.map((pipe) => ({ - structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5) type_id: pipe.facility, placement: "point", chainage_m: pipe.chainage_m, @@ -278,7 +205,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */ async function saveStructuresIfDirty(): Promise { - if (readPending() === null) return; + if (readPending() === null && !definitionStale(deps.projectId)) return; // 옛 판 = 판 도장(323-9) // 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다. // 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는 // 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐). @@ -346,12 +273,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. + await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), fetchStructures(deps.projectId), ]); - deps.panel().structures.setTypes(allTypes, (type) => type.enabled); + deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1) deps.profilePanel().setStructureTypes(allTypes); + planTypes = allTypes; // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( allTypes.map((type) => [ @@ -366,6 +295,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ]), ); structureRevision = stored.revision; + const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2 + if (note) showToast(note); // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). const pending = readPending(); ownStructures = pending ?? stored.structures; @@ -404,7 +335,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group, /** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */ setPipes(pipes: PipeProjection[]): void { - pipeStations = pipesToStations(pipes); + planPipes = [...pipes]; syncCrossDrainStations(); pipeMarks = pipesToMarks(pipes); syncGraphStructures(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts index 0cebd0aa0..1c30e739c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -14,7 +14,7 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; import { @@ -43,7 +43,7 @@ export function balloonLines( head: { name: string; position: string }, count?: ContainerCount, ): string[] { - const spec = STRUCTURE_SPECS[structure.type_id]; + const spec = specFor(structure.type_id); const values = ( spec ? specLines(spec, structure, type, count) : groupLines(structure.options ?? {}, type) ).filter(Boolean); @@ -158,7 +158,7 @@ export function structureOptionLines( const kept = parts.filter(Boolean); return kept.length ? [`${title}: ${kept.join(SEP)}`] : []; }; - const spec = STRUCTURE_SPECS[structure.type_id]; + const spec = specFor(structure.type_id); if (spec) { const names = specNames(structure as StructureInstance, type); const state = formState(spec, structure.type_id, options); diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts index b3ef3f123..752627e1b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -186,7 +186,10 @@ export function shapeFields( export interface StructureDefinition { 판?: number; 구조물군?: Record; - 종류?: Record }>; + 종류?: Record< + string, + { 기준측점?: boolean; 동작?: string[]; 이름?: Record; 본보기?: string } + >; 그룹?: Record>; 배치?: Record>; 규칙?: DefinedRule[]; @@ -216,7 +219,7 @@ export function withDefinition< }); } -/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 고르기 목록에서 빼고 대피로 + 상세로 읽음. +/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 대피로 + 상세로 읽음(고르기 목록 숨김 = 구조물 표 처음값 hidden · 322-1). * 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */ export const ROAD_TYPE = "evac_road"; export const ROAD_DETAILS: Readonly> = { @@ -225,9 +228,6 @@ export const ROAD_DETAILS: Readonly> = { turnaround: "차돌림곳", }; -/** 고르기 목록에 낼 종류인지 — 옛 대피 종류만 뺌. */ -export const pickable = (type: { type_id: string }): boolean => !ROAD_DETAILS[type.type_id]; - /** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */ export function asRoad }>( record: T, diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index a0884d884..82e43d2d8 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -101,6 +101,11 @@ export function optionControl( return element; } +/** 정의 「표시」로 숨긴 옵션 키(서버 `visible` 거짓 · 322-2) — 명세 폼이 그 칸 · 상자를 안 그림. */ +export const hiddenOptionKeys = ( + options: ReadonlyArray<{ key: string; visible?: boolean }>, +): Set => new Set(options.filter((o) => o.visible === false).map((o) => o.key)); + /** 새 구조물 칸에 실을 등록부 기본값(PLAN 46-7) — 기본값이 있는 칸만 · 없는 칸은 빈 칸(미확정). */ export function optionDefaults( options: ReadonlyArray<{ key: string; default: string | number | null }>, diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index 71bdf7beb..aac595a29 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -23,7 +23,7 @@ import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; import { numberInput } from "./B05_Profile_UI_Structures_Fields"; import { grid, group as boxOf, labeled, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { GROUP_JOIN, type GroupItems, @@ -117,7 +117,7 @@ export function createGroupBoxes(): GroupBoxes { shapeInputs.clear(); current = type; // 명세 종류(PLAN 52 · 배관)는 컨테이너가 잇기 줄을 가짐 — 그룹 상자를 안 세움 - const groups = type && STRUCTURE_SPECS[type.type_id] ? [] : (type?.groups ?? []); + const groups = type && specFor(type.type_id) ? [] : (type?.groups ?? []); const boxes: HTMLElement[] = []; bodies = []; // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) @@ -200,9 +200,7 @@ export function createGroupBoxes(): GroupBoxes { bodies.push(body); } const stale = - type && !STRUCTURE_SPECS[type.type_id] - ? paintStale(new Set(groups.map((g) => g.id))) - : null; + type && !specFor(type.type_id) ? paintStale(new Set(groups.map((g) => g.id))) : null; root.replaceChildren(...boxes, ...(stale ? [stale] : [])); root.hidden = !type || (!boxes.length && !stale); // 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로 diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 4efc915fc..424ab6a62 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -22,7 +22,7 @@ import { summaryTooltip, type SummaryItem, } from "../common_util/common_util_summary_item"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import { @@ -103,7 +103,7 @@ export function linkLamps( ): LinkLamp[] { const links = readGroupItems(source.group_items); const summary = readSummaryItem(source.summary_item); - const spec = STRUCTURE_SPECS[source.type_id]; + const spec = specFor(source.type_id); if (spec?.containers.length) { const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); // 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음) diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 42f64a4a4..acf44b1c5 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -24,6 +24,7 @@ import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List" import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { frameOf } from "../common_util/common_util_view_window"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 * 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여 @@ -174,7 +175,12 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement // 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는 // 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다. // 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다. - if (structure.placement === "interval") { + // 띠 = 표 자리꼴 구간(322-1) · 관 지점 시설(물넘이포장 · 기슭막이)은 알약만(지금 그대로) + const listed = catalogRow(structure.type_id); + if ( + (listed?.placement ?? structure.placement) === "interval" && + listed?.owner !== "pipe_points" + ) { const band = document.createElement("div"); band.className = `b05-structure__band${selected ? " is-selected" : ""}`; const startPx = options.x(structure.start_m ?? 0); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6c28e06a5..05e25caf4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -43,7 +43,7 @@ import { } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; import { addsAtOnce, finishAdd } from "./B05_Profile_UI_Structures_Panel_AddAt"; -import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define"; +import { showsAnchor } from "./B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; @@ -352,7 +352,7 @@ export function createStructuresSection( * 초기·리셋 상태를 "안 고름"으로 드러낸다. */ function syncTypeOptions(keepTypeId?: string): void { const group = groupSelect.value; - const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : []; + const candidates = group ? types.filter((t) => t.group === group) : []; typeSelect.replaceChildren(new Option("— 선택 —", ""), ...typeOptions(candidates, canPick)); typeSelect.value = keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : ""; @@ -465,9 +465,7 @@ export function createStructuresSection( const loadPipeForm = (pipe: PipeFacilityItem): void => loadPipeFormInto(panelContext, pipe); const emit = (): void => emitFrom(panelContext); const commit = (live = false): Promise => commitInto(panelContext, live); - - /* 입력 배선은 700줄 제한으로 `_Structures_Panel_Events` 로 옮겼다 — 처리 함수는 여기 그대로다. */ - /* 저장·배선이 함께 보는 패널 컨텍스트 — 가변 상태의 주인은 그대로 패널이다. */ + /* 입력 배선(700줄 제한 · `_Structures_Panel_Events`)과 저장이 함께 보는 컨텍스트 — 가변 상태 주인 = 패널. */ const panelContext: StructuresEventContext = { groupSelect, typeSelect, @@ -493,6 +491,7 @@ export function createStructuresSection( commitPosition: () => commitPosition(), liveCommit: () => liveCommit(), loadForm, + addAt: (at, typeId) => api.addAt(at, typeId), // 정본 다른 종류로 바꿈 = 같은 측점 새로 놓기(217) cancelTempPipe: () => cancelTempPipe(), syncTypeOptions: (keepTypeId) => syncTypeOptions(keepTypeId), syncPlacementFields: () => syncPlacementFields(), @@ -509,17 +508,14 @@ export function createStructuresSection( readBeforeM, syncFacilityForm: (options, designFlow) => syncFacilityForm(options, designFlow), editingId: () => editingId, - setEditingId: (value) => { - editingId = value; - }, + setEditingId: (value) => void (editingId = value), selectedPipeChainage: () => selectedPipeChainage, setSelectedPipeChainage: (value) => { selectedPipeChainage = value; }, tempPipeChainage: () => tempPipeChainage, - setTempPipeChainage: (value) => { - tempPipeChainage = value; - }, + setTempPipeChainage: (value) => void (tempPipeChainage = value), + selectPipe: (at) => api.selectPipeByChainage(at), // 종류 바꿈 못 함 = 옛 관 폼으로(323-6) positionConfirmed: () => positionConfirmed, setPositionConfirmed: (value) => { positionConfirmed = value; @@ -543,7 +539,7 @@ export function createStructuresSection( syncButtons(); renderList(); - return { + const api: StructuresSection = { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) listRoot: list, @@ -671,7 +667,7 @@ export function createStructuresSection( renderOptionFields(); syncFacilityForm(); if (addsAtOnce(type, optionInputs)) - return void finishAdd({ type, anchorM: chainageM, structures, commit, pick: this }); + return finishAdd({ type, anchorM: chainageM, structures, commit, pick: this }); // 임시 배치도 즉시 진행(A군). if (type.managed_by) commitTempPipe(true); syncButtons(); @@ -697,4 +693,5 @@ export function createStructuresSection( return true; }, }; + return api; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts index 22d989a3b..be20709b1 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_AddAt.ts @@ -10,10 +10,10 @@ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structu import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import { showToast } from "@ui/ui_template_elements"; -/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창). */ -const FORM_ONLY = new Set(["pavement_concrete"]); -/** 월류 높이를 유량 뒤에 받는 종류 — 추가 때 알림 한 줄. */ -const FORD_KINDS = new Set(["ford_bridge", "ford_pavement"]); +/** 바로 추가하지 않는 동작 — 포장 · 물넘이 겹침(확인창 · 323-7 종류 키 대신 동작 이름). */ +const FORM_ONLY = "포장_물넘이_겹침"; +/** 월류 높이를 유량 뒤에 받는 동작 — 추가 때 알림 한 줄. */ +const FORD_KINDS = "월류단면"; export const FORD_WAIT_NOTICE = "월류 높이는 배수유역 재계산(설계유량)이 오면 채워집니다."; /** 우클릭 한 번으로 [추가]까지 끝낼 수 있는지 — [추가]가 막는 빈 필수 칸이 없어야 함. */ @@ -21,7 +21,7 @@ export function addsAtOnce( type: StructureType, optionInputs: ReadonlyArray<{ required: boolean; isEmpty: () => boolean }>, ): boolean { - if (FORM_ONLY.has(type.type_id)) return false; + if (type.behaviors?.includes(FORM_ONLY)) return false; return !optionInputs.some((entry) => entry.required && entry.isEmpty()); } @@ -40,7 +40,7 @@ export async function finishAdd(ctx: { // B05 는 재계산 뒤 배수유역 패널이 새 관 고름을 다시 알려 폼이 열림. await ctx.commit(); ctx.pick.selectPipeByChainage(ctx.anchorM); - if (FORD_KINDS.has(ctx.type.type_id)) showToast(FORD_WAIT_NOTICE); + if (ctx.type.behaviors?.includes(FORD_KINDS)) showToast(FORD_WAIT_NOTICE); return true; } const before = new Set(ctx.structures.map((entry) => entry.structure_id)); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 7e3c6c64b..aabcc1501 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -13,6 +13,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; +import { showToast } from "@ui/ui_template_elements"; 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"; @@ -21,7 +22,7 @@ import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec"; import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { snapStructureStation } from "../common_util/common_util_structure_station"; import { readGroupItems, @@ -116,7 +117,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance const links = readGroupItems(target.group_items); const summary = readSummaryItem(target.summary_item); // 정의 그룹 종류는 종류 단위 잇기 줄이 없음 — 옛 종류 단위 값은 첫 그룹 컨테이너 잇기로 읽음(54-6) - const first = type && !STRUCTURE_SPECS[type.type_id] ? type.groups?.[0]?.id : undefined; + const first = type && !specFor(type.type_id) ? type.groups?.[0]?.id : undefined; const moved = !!(first && summary && !links[first]); ctx.setSummary(moved ? null : summary); ctx.groupBoxes.set(moved ? { ...links, [first!]: summary! } : links); @@ -249,15 +250,19 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis const type = ctx.currentType(); if (!type) return; const step = ctx.interval(); + /** 막힌 칸으로 커서 · [추가](실시간 반영 아님)면 어느 칸인지 알림(323-6 · 무반응이던 것). */ + const stop = (input: HTMLElement | null | undefined, field: string): void => { + input?.focus(); + if (!live) showToast(`「${field}」 칸이 비었거나 맞지 않아 못 놓음 — 칸을 채우세요.`, "error"); + }; + const labelOf = (key: string): string => + type.options.find((option) => option.key === key)?.label ?? key; // 계곡 통과 시설 — 기준 측점 + 구간 + 부속 옵션을 관 지점 정본으로 보낸다 // (시설 종류 = type_id). 목록에서 골라 온 경우는 수정(기준점 이동 포함)이다. if (type.managed_by) { const anchor = ctx.anchorFields.read(step, true); - if (anchor === null) { - ctx.anchorFields.station.focus(); - return; - } + if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); // 배수관 등 계곡 통과 시설은 점형 — 기준 측점 하나뿐이다(2026-08-17 지시 2). const attributes: FacilityAttributes = { facility: type.type_id as PipeFacility }; const options = readOptions(ctx); @@ -288,14 +293,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis // 범위 계산 타입(C군 등) — 시작 = 기준 − 전길이, 종료 = 시작 + 길이 // (2026-08-19 사용자 지시: 전/후 분할). 전길이가 기준을 넘어 시작이 음수면 거절. anchor = ctx.anchorFields.read(step, true); - if (anchor === null) { - ctx.anchorFields.station.focus(); - return; - } + if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); const length = ctx.readLengthM(); if (length === null) { - ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus(); - return; + const input = ctx.optionInputs.find((entry) => entry.key === "length_m")?.input; + return stop(input, labelOf("length_m")); } // 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인 // 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`). @@ -303,8 +305,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis if (anchor - before < -0.005) { const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input; beforeInput?.classList.add("is-invalid"); - beforeInput?.focus(); - return; + return stop(beforeInput, labelOf("before_m")); } // 시작 · 끝도 정수 m · 끝은 1 m 이상(234-1 · 167) start = snapStructureStation(Math.max(anchor - before, 0)); @@ -312,35 +313,27 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } 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; - } + if (start === null || end === null || end <= start) + return stop( + start === null ? ctx.startFields.station : ctx.endFields.station, + "시작 · 끝 측점", + ); // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. // 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점. - anchor = STRUCTURE_SPECS[type.type_id]?.byRange - ? start - : (ctx.anchorFields.read(step, false) ?? start); + anchor = specFor(type.type_id)?.byRange ? start : (ctx.anchorFields.read(step, false) ?? start); if (anchor < start || anchor > end) { ctx.anchorFields.station.classList.add("is-invalid"); - ctx.anchorFields.station.focus(); - return; + return stop(ctx.anchorFields.station, "기준 측점(시작 ~ 끝 안)"); } } else { anchor = ctx.anchorFields.read(step, true); - if (anchor === null) { - ctx.anchorFields.station.focus(); - return; - } + if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). // 실시간 반영은 막지 않음 — 빈 칸은 표시 · 안내만(`markRequired` · 54-12), 막기는 [추가] · 서버 [저장] const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing && !live) { - missing.input.focus(); - return; - } + if (missing && !live) return stop(missing.input, labelOf(missing.key)); const summary = ctx.summary(); const base = { @@ -362,7 +355,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis // 포장 구간은 물넘이포장과 겹칠 수 없다 — 통째로 품으면 나누고, 끝에 걸치면 멈춘다 // (2026-08-28 사용자 확정). 다른 타입은 원래 구간 한 벌 그대로다. const spans = - type.type_id === "pavement_concrete" && start !== null && end !== null + type.behaviors?.includes("포장_물넘이_겹침") && start !== null && end !== null ? await splitPavementRange(start, end, ctx.pipeFacilities()) : [{ start, end }]; if (!spans) return; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 392223cf9..a9e3ebaad 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -8,13 +8,18 @@ * 처리 함수(commit·loadForm 등)는 그대로 패널 소유다 — 여기서는 이어 붙이기만 한다. * ========================================================================== */ -import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "./B05_Profile_Api_Structures"; import type { StructuresCommitContext } from "./B05_Profile_UI_Structures_Panel_Commit"; import { asRoad } from "./B05_Profile_UI_Structures_Define"; import { readState, writeState } from "../A00_Common/b_page_state"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "../A00_Common/design/design_selection"; import { readGroupItems, readSummaryItem } from "../common_util/common_util_summary_item"; -import { showConfirmDialog } from "@ui/ui_template_elements"; +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { swapsInPlace } from "../common_util/common_util_structure_catalog"; import { forgetStationAdjusts } from "../B06_Section/B06_Section_Api_Culvert_Options"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ @@ -34,6 +39,10 @@ export interface StructuresEventContext extends StructuresCommitContext { commitPosition: () => void; liveCommit: () => void; loadForm: (target: StructureInstance | null) => void; + /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt` · 놓였으면 true). */ + addAt: (chainageM: number, typeId: string) => Promise | void; + /** 관 하나를 폼에 엶(패널 `selectPipeByChainage`). */ + selectPipe: (chainageM: number) => void; emit: () => void; optionRow: HTMLElement; } @@ -82,9 +91,14 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { ? null : ctx.pipeFacilities().find((entry) => Math.abs(entry.chainage_m - pipe) < PIPE_MATCH_M); const prev = ctx.typeMap().get(record ? asRoad(record).type_id : (pipeItem?.facility ?? "")); - // 정본이 다른 종류(관 지점 ↔ 구조물)로 · 빈칸으로 = 새로 놓기 - if (!next || !prev || !!next.managed_by !== !!prev.managed_by) - return startNew(ctx.groupSelect.value, next?.type_id); + if (!next || !prev) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기 + // 표 규칙(323-6 · `swapsInPlace`) — 정본이 다르거나(관 지점 ↔ 구조물) 구조물 자리꼴이 다르면(E · F 점형 ↔ + // 구간형) 옛 것을 지워 「없음」을 거친 뒤 같은 기준 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 + // 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림 + if (!swapsInPlace(prev.type_id, next.type_id)) { + const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; + return void replaceAt(at, prev, next, record); + } const key = record ? `s:${id}` : `p:${pipe}`; // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 stashTypeValues( @@ -116,8 +130,37 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { // 관 지점 종류 바꿈 — 그 측점 옛 조정값(세션)은 버리고 [저장] 때 정본에서도 비움(318-4) if (pipeItem) forgetStationAdjusts(ctx.projectId, pipeItem.chainage_m, prev.type_id, next.type_id); - // 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림 - if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record; + // 반영이 막히면(측점 · 필수 칸 등) 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰 · 323-6) + if (record && draft && ctx.structures[index] === draft) { + ctx.structures[index] = record; + ctx.loadForm(record); + showToast(`${next.name}(으)로 못 바꿈 — 폼 칸(측점 · 필수 칸)을 확인하세요.`, "error"); + } + } + + /** 지우고 같은 기준 측점에 새로 — 새 종류가 기본값으로 놓인 뒤에만 옛 것을 지움. 못 놓으면(빈 칸 · + * [추가] 가 막음) 옛 것 · 옛 폼 그대로 + 알림(323-6 고침 · 조용히 사라지던 것). */ + async function replaceAt( + at: number, + prev: { type_id: string; name: string }, + next: { type_id: string; name: string }, + record: StructureInstance | null, + ): Promise { + if (!(await ctx.addAt(at, next.type_id))) { + if (record) ctx.loadForm(record); + else ctx.selectPipe(at); + showToast( + `${prev.name} 그대로 — ${next.name}(으)로 못 바꿈(빈 칸 때문에 바로 못 놓음)`, + "error", + ); + return; + } + if (record) { + const index = ctx.structures.findIndex((e) => e.structure_id === record.structure_id); + if (index >= 0) ctx.structures.splice(index, 1); + ctx.emit(); + } else ctx.callbacks.onPipeRemove(at); + forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id); } type TypeValues = { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index f10f307d8..6c3227f2a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -21,14 +21,15 @@ import { } from "./B05_Profile_UI_Structures_Define"; import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; +import { baseType } from "../common_util/common_util_structure_catalog"; import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec"; import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec"; /** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로 * 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */ export const hidesTypeSummary = (type: StructureType): boolean => - !!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B"; + !!specFor(type.type_id)?.containers.length || !!type.groups?.length || type.group === "B"; /** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일 · 표 09 흙막이 · 옹벽). */ const SOLVE_SPLIT = new Set(["guardrail", "soil_guard", "retaining_wall"]); @@ -150,7 +151,7 @@ export function renderOptionFields( "length" | "before_m" | "after_m"; const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); - if (SOLVE_SPLIT.has(type!.type_id)) { + if (SOLVE_SPLIT.has(baseType(type!.type_id))) { solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게 ctx.syncRangeDisplay(); } else ctx.syncSplitInputs(source, normalize); @@ -273,7 +274,7 @@ export function paintStale( shapeKeys: string[] = [], ): void { // 명세 종류(52) — 명세가 뺀 옛 키(retired)는 지난 정의 값 · 선택 컨테이너 키(머리)는 앎 - const spec = type ? STRUCTURE_SPECS[type.type_id] : undefined; + const spec = type ? specFor(type.type_id) : undefined; const own = spec ? specKeys(spec) : { keys: [], prefixes: [] }; const known = [ ...shapeKeys, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 71c974d11..f47e9df0a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -20,8 +20,8 @@ import { } from "./B05_Profile_Api_Structures"; import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; -import { pickable } from "./B05_Profile_UI_Structures_Define"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ @@ -36,22 +36,6 @@ export const GROUP_LABELS: Record = { 호환: "기타", }; -/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07). - * 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */ -export const HIDDEN_PICK_GROUPS: ReadonlySet = new Set(["B", "D"]); - -/** 고르기 목록에서 뺄 종류 — 사면안정은 흙막이 · 옹벽 · 소단만(54-31). 옛 값은 그대로. */ -export const HIDDEN_PICK_TYPES: ReadonlySet = new Set([ - "masonry_wet", - "masonry_dry", - "boulder_masonry", -]); - -/** 새로 놓을 때 고르기 목록에서 가릴 종류인가 — 뺄 군이거나 뺄 종류. */ -export function isPickHidden(type: { group: string; type_id: string }): boolean { - return HIDDEN_PICK_GROUPS.has(type.group) || HIDDEN_PICK_TYPES.has(type.type_id); -} - /** 고르기 목록 항목 — 가릴 항목은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */ export function pickOption(text: string, value: string, hidden: boolean): HTMLOptionElement { const option = new Option(text, value); @@ -60,9 +44,10 @@ export function pickOption(text: string, value: string, hidden: boolean): HTMLOp return option; } -/** 고르기 목록에서 가릴 종류인가 — 꺼진(pick) 종류 · 뺄 군 · 뺄 종류(드롭다운 · 메뉴 공용). */ +/** 고르기 목록에서 가릴 종류인가 — 페이지 거르기(`listPick` · 구조물 표 목록 · 322-1)가 고름. 옛 군 · 종류 + * 숨김(종단배수 · 계류사방 · 돌쌓기 셋 · 옛 대피 셋)은 표 처음값 hidden(드롭다운 · 메뉴 공용). */ const pickHidden = (type: StructureType, pick: (type: StructureType) => boolean): boolean => - !pick(type) || isPickHidden(type); + !pick(type); /** 고르기 목록 한 군 — 가린 군 · 종류도 담음(옛 항목을 열 때 값으로 고름). */ export interface PickGroup { @@ -73,20 +58,25 @@ export interface PickGroup { } /** 군 → 종류 고르기 목록 한 벌 — 패널 드롭다운과 우클릭 메뉴(종단 · 배수유역도 · 3D · B06)가 - * 같은 자료를 봄(129-1). 군 이름은 정의(48) 먼저 · 옛 대피 종류는 뺌 · 꺼진(pick) 종류 · 뺄 군 · - * 뺄 종류는 가림 · 보일 종류가 없는 군도 가림. */ + * 같은 자료를 봄(129-1). 군 이름은 정의(48) 먼저 · 군 차례 = 정의 구조물군 차례 · 군 안 = 종류 차례 · + * 페이지 거르기(pick)가 안 고른 종류는 가림 · 보일 종류가 없는 군도 가림. */ export function pickGroups( types: ReadonlyArray, pick: (type: StructureType) => boolean, ): PickGroup[] { const groups = new Map(types.map((t) => [t.group, t.group_name] as const)); - return [...groups].map(([group, name]) => { - const members = types - .filter((t) => t.group === group && pickable(t)) - .map((type) => ({ type, hidden: pickHidden(type, pick) })); - const hidden = members.every((member) => member.hidden); - return { group, label: name ?? GROUP_LABELS[group] ?? group, hidden, types: members }; - }); + const rank = (group: string): number => + catalogRow(types.find((t) => t.group === group)?.type_id ?? "")?.groupOrder ?? + Number.MAX_SAFE_INTEGER; + return [...groups] + .sort(([a], [b]) => rank(a) - rank(b)) + .map(([group, name]) => { + const members = types + .filter((t) => t.group === group) + .map((type) => ({ type, hidden: pickHidden(type, pick) })); + const hidden = members.every((member) => member.hidden); + return { group, label: name ?? GROUP_LABELS[group] ?? group, hidden, types: members }; + }); } /** 군 드롭다운 항목들 — `pickGroups` 그대로. */ @@ -184,8 +174,8 @@ export interface StructuresSection { selection: DesignSelection, coverable?: (entry: StructureInstance) => boolean, ) => ResolvedSelection; - /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ - addAt: (chainageM: number, typeId: string) => void; + /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. 바로 놓으면 놓였는지(true) · 폼만이면 없음. */ + addAt: (chainageM: number, typeId: string) => Promise | void; /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ moveById: (structureId: string, toChainageM: number) => boolean; removeById: (structureId: string) => boolean; @@ -245,7 +235,7 @@ export function readBeforeOption(inputs: OptionEntry[]): number { /** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */ export const isRangeOnly = (type: { type_id: string } | null): boolean => - !!type && !!STRUCTURE_SPECS[type.type_id]?.byRange; + !!type && !!specFor(type.type_id)?.byRange; /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts index 982a3e666..d42ace5a7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Snap.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -125,11 +125,16 @@ export function snapOldStations(ctx: SnapContext): number { } plan.pipes.forEach(({ from, to, attributes }) => ctx.movePipe(from, to, attributes)); } - if (count || plan.skipped) - showToast( - (count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") + - (plan.skipped ? ` 맞출 자리에 다른 관이 있는 관 ${plan.skipped}건은 그대로 둡니다.` : ""), - plan.skipped ? "info" : "success", - ); + snapNotice(count, plan.skipped); return count; } + +/** 옛 소수 맞춤 알림 한 줄 — B05 종단 보기 · B06 구조물 목록(관)이 같은 글. */ +export function snapNotice(count: number, skipped: number): void { + if (count || skipped) + showToast( + (count ? `옛 소수 측점 ${count}건을 정수 m 로 옮겼습니다 — [저장]하면 남습니다.` : "") + + (skipped ? ` 맞출 자리에 다른 관이 있는 관 ${skipped}건은 그대로 둡니다.` : ""), + skipped ? "info" : "success", + ); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index a9ab220df..56a1661f3 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -15,10 +15,11 @@ import { type SpecFormHooks, type SpecLinks, } from "../A00_Common/design/design_structure_form"; -import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specFor } from "../A00_Common/design/design_structure_pipe"; import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; +import { baseType } from "../common_util/common_util_structure_catalog"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; -import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; +import { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; const forms = new WeakMap(); @@ -27,7 +28,7 @@ const widenings = new WeakMap(); /** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */ export const structureSpecOf = (type: StructureType | null): StructureSpec | null => - (type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) || + (type && !type.managed_by && (specFor(type.type_id) ?? ADDON_SPECS[baseType(type.type_id)])) || null; /** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ @@ -86,7 +87,15 @@ export function paintStructureSpec( form.root.hidden = !spec; if (!spec) return; const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); - form.write(spec, values, type?.spec_names ?? {}, groups, optionDefaults(type?.options ?? [])); + const options = type?.options ?? []; + form.write( + spec, + values, + type?.spec_names ?? {}, + groups, + optionDefaults(options), + hiddenOptionKeys(options), + ); } /** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */ diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index b48cd5216..6dc1f1cc4 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -10,6 +10,8 @@ * ========================================================================== */ import * as THREE from "three"; +import { pickLights } from "../A00_Common/design/design_pick_match"; +import { isNear } from "../A00_Common/design/design_selection"; /** 3D에서 고른 부재의 신원표 — 메쉬 `userData`에 실려 있는 그 값이다. */ export interface StructurePick { @@ -17,6 +19,8 @@ export interface StructurePick { kind: string; /** 부재키(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·`basin`·`pipe`). 없을 수 있다. */ key?: string; + /** 구조물 id(323-5 · C군 벽) — 있으면 id 로 맞춤(고름 강조 한 규칙 `pickLights`). */ + structureId?: string | null; } export interface StructurePickControls { @@ -64,18 +68,31 @@ export function bindStructurePick(options: { while (node) { const data = node.userData as Partial; if (typeof data.chainageM === "number") { - return { chainageM: data.chainageM, kind: String(data.kind ?? ""), key: data.key }; + return { + chainageM: data.chainageM, + kind: String(data.kind ?? ""), + key: data.key, + structureId: data.structureId, + }; } node = node.parent; } return null; } - /** 이 부재가 지금 고른 것인가 — 부재키가 없는 선택(측점만)은 그 측점 전부를 켠다. */ + /** 이 부재가 지금 고른 것인가 — 고름 강조 한 규칙(카드 형상과 같음 · 부재키가 없는 선택 = 그 자리 전부). */ + const asSelection = (pick: StructurePick) => ({ + chainageM: pick.chainageM, + structureId: pick.structureId ?? null, + partKey: pick.key, + }); + const asTag = (pick: StructurePick) => ({ + chainageM: pick.chainageM, + structureId: pick.structureId ?? null, + part: pick.key, + }); 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; + return !!selected && pickLights(asSelection(selected), asTag(tag)); } function applyHighlight(): void { @@ -138,8 +155,8 @@ export function bindStructurePick(options: { if (!pick) return; const same = selected !== null && - Math.abs(selected.chainageM - pick.chainageM) < 0.01 && - selected.key === pick.key; + selected.key === pick.key && + pickLights(asSelection(selected), asTag(pick)); selected = same ? null : pick; applyHighlight(); controls.onPick?.(selected); @@ -162,7 +179,7 @@ export function bindStructurePick(options: { selectAtChainage(chainageM) { // 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다 — 되돌아온 동기화가 // 부재 하나짜리 강조를 그 측점 전체로 넓히면 안 된다(2026-09-04). - if (chainageM !== null && selected && Math.abs(selected.chainageM - chainageM) < 0.01) return; + if (chainageM !== null && selected && isNear(selected.chainageM, chainageM)) return; selected = chainageM === null ? null : { chainageM, kind: "" }; applyHighlight(); }, diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 9a19ca636..817804c38 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -339,6 +339,8 @@ export interface CrossSection extends SectionStation { pavement?: PavementEntry[]; /** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */ underdrain?: UnderdrainSpec; + /** 빈칸 경고(323-4 · 읽기 칸 · 저장 안 함) — 「종류 칸」(형상 필수 칸이 빈 관 · 구조물 · 측점 계획 자리). */ + blank?: string[]; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Engine_Attach.py b/B06_Section/B06_Section_Engine_Attach.py new file mode 100644 index 000000000..6ceebed9c --- /dev/null +++ b/B06_Section/B06_Section_Engine_Attach.py @@ -0,0 +1,105 @@ +"""형상 얹기 한 벌(323-4) — 측점(횡단 dict) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료. + +구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 → +빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀. +TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) · +거울 시험 = `resources/tester/test_323_4_attach_mirror.py`. +""" + +from __future__ import annotations + +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 +from B06_Section.B06_Section_Engine_Culvert import ( + attach_culvert_specs, + culvert_sets_from, + pipe_points_file, +) +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_specs, wall_specs_from +from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from +from common_util.common_util_drainage_pipes import read_pipe_points_file +from common_util.common_util_structure_catalog import catalog_row, field_name, type_ids_with +from common_util.common_util_structure_pavement import ( + attach_pavement_specs, + pavement_specs_from, +) +from common_util.common_util_structure_station import ( + SAME_STATION_M, + blank_keys, + plan_stations, +) + +logger = logging.getLogger(__name__) + +#: 측점 그림 자료 열쇠 — TS `common_util_structure_shape_keys.SHAPE_KEYS` 짝. +SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") + + +def _blank_labels(type_id: str, options: dict | None, types: dict[str, Any]) -> list[str]: + """빈 필수 칸 → 「종류 이름 칸 이름」(TS `blankLabels` 짝).""" + name = (catalog_row(type_id) or {}).get("name") or type_id + ordered = [types[type_id]] if type_id in types else [] + ordered += list(types.values()) + out = [] + for key in blank_keys(type_id, options): + label = next( + (o.label for t in ordered for o in t.options if o.key == key and o.label), None + ) + out.append(f"{name} {label or field_name(key) or key}") + return out + + +def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None: + """빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(TS `attachBlanks` 짝).""" + types = structure_type_map() + owners: dict[str, tuple[str, dict | None]] = {} + for pipe in pipes: + owner = getattr(pipe, "id", None) or f"pipe-{float(pipe.chainage_m):.2f}" + owners[owner] = (pipe.facility or "pipe", pipe.options) + for item in structures: + if item.structure_id: + owners[item.structure_id] = (item.type_id, item.options) + for station in plan_stations(pipes, structures): + if not station["blank"]: + continue + labels: list[str] = [] + for owner in station["owners"]: + hit = owners.get(owner) + for label in _blank_labels(hit[0], hit[1], types) if hit else []: + if label not in labels: + labels.append(label) + for section in sections: + at = section.get("chainage_m") + if isinstance(at, (int, float)) and abs(at - station["chainage_m"]) < SAME_STATION_M: + section["blank"] = labels + + +def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None: + """측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침).""" + for section in sections: + for key in (*SHAPE_KEYS, "blank"): + section.pop(key, None) + attach_culvert_specs(sections, culvert_sets_from(pipes)) + # 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음 + attach_wall_specs(sections, wall_specs_from(structures)) + attach_pavement_specs( + sections, + pavement_specs_from([s.model_dump() for s in structures], type_ids_with(shape="pavement")), + ) + attach_underdrain_specs(sections, underdrains_from(structures)) + _attach_blanks(sections, pipes, structures) + + +def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None: + """저장분 현황(관 지점 · 구조물 정본)으로 얹음 — 구조물 정본을 못 읽으면 관만(횡단 조회는 이어 감).""" + pipes = read_pipe_points_file(pipe_points_file(project_root)) + try: + structures = load_structures(str(project_root))[1] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 + logger.exception("B06 구조물 정본을 읽지 못했습니다 (관만 얹음)") + structures = [] + attach_shapes(sections, pipes, structures) diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 67d055929..4e895efe1 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -25,19 +25,13 @@ import json import logging import math import re -from functools import lru_cache from pathlib import Path from typing import Any from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -from common_util.common_util_drainage_pipes import ( - PIPE_FACILITY_BOX, - PIPE_FACILITY_FORD_BRIDGE, - PIPE_FACILITY_FORD_PAVEMENT, - PIPE_FACILITY_PIPE, - PIPE_FACILITY_REVET, - parse_pipe_points, -) +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_catalog import catalog_row +from common_util.common_util_structure_station import EDGE_TOLERANCE_M from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_EDITS_DIRNAME, @@ -46,8 +40,8 @@ from config.config_system import ( logger = logging.getLogger(__name__) -# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. -_CHAINAGE_TOLERANCE_M = 0.02 +# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7) +_CHAINAGE_TOLERANCE_M = EDGE_TOLERANCE_M # 세트 종류 -> 횡단 dict 키. 그림이 다른 시설끼리 소비처가 섞이지 않게 나눠 둔다. _SECTION_KEYS = {"ford": "ford", "box": "box", "ford_pavement": "ford_pavement"} @@ -147,12 +141,12 @@ def pipe_points_file(project_root: Path) -> Path: ) -@lru_cache(maxsize=4) def _registry_defaults(type_id: str) -> dict[str, Any]: """레지스트리 타입 옵션의 기본값 묶음. 저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는 - 기본값과 항상 같은 값이 그림에 들어간다. + 기본값과 항상 같은 값이 그림에 들어간다. 캐시 없음(322-0) — 정의 읽기가 이미 파일 시각으로 + 갈려 M02 저장 즉시 새 기본값(옛 lru 는 서버를 다시 켤 때까지 옛 값). """ structure_type = structure_type_map().get(type_id) if structure_type is None: @@ -611,20 +605,27 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: except (OSError, json.JSONDecodeError): logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path) return {} + return culvert_sets_from(parse_pipe_points(document.get("points"))) + + +def culvert_sets_from(points: list[Any]) -> dict[float, dict[str, Any]]: + """관 지점 목록 → 누가거리(소수 2자리)별 세트 제원(TS `buildCulvertSets` 짝 · 형상 얹기 한 벌 323-4).""" sets: dict[float, dict[str, Any]] = {} - for point in parse_pipe_points(document.get("points")): - if point.facility == PIPE_FACILITY_PIPE: + for point in points: + # 그릴 세트 = 구조물 표 형상(322-1 · TS `buildCulvertSets` 짝) + shape = (catalog_row(point.facility) or {}).get("shape") + if shape == "pipe_set": if (point.options or {}).get("pipe_diameter_mm") in (None, ""): continue # 관경 안 고름 = 관 없음(54-39) spec = _culvert_set(point.options) - elif point.facility == PIPE_FACILITY_FORD_BRIDGE: + elif shape == "ford": spec = _ford_set(point.options) - elif point.facility == PIPE_FACILITY_BOX: + elif shape == "box": spec = _box_set(point.options) - elif point.facility == PIPE_FACILITY_FORD_PAVEMENT: + elif shape == "ford_pavement": # 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28). spec = _ford_pavement_set(point.options) - elif point.facility == PIPE_FACILITY_REVET: + elif shape == "hidden_revet": # 독립 기슭막이 — 배관 세트 모양이되 관을 숨긴다. `culvert` 키로 얹혀 배관 # 경로(컴퓨트·렌더·패널·연동·경사·3D·수량)를 그대로 탄다. spec = _revet_set(point.options) @@ -636,7 +637,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다.""" - sets = load_culvert_sets(project_root) + return attach_culvert_specs(cross_sections, load_culvert_sets(project_root)) + + +def attach_culvert_specs( + cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]] +) -> int: + """세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다.""" if not sets: return 0 attached = 0 diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index 5ee2cd263..7fd1cfffc 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -28,14 +28,13 @@ from B06_Section.B06_Section_Engine_Design import ( stored_shoulder_extend, stored_tilt, ) +from common_util.common_util_structure_catalog import catalog_row from common_util.common_util_structure_face_role import structure_face_role #: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다. _SOLVE_STEPS = 24 #: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다. _MAX_PROBE_WIDTH_M = 1000.0 -#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장). -SPOIL_TYPE_ID = "spoil_bank" #: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다. _SIDE_AUTO = "자동(성토 쪽)" _SIDE_WORDS = {"좌": "left", "우": "right"} @@ -108,7 +107,7 @@ def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]: type_id = getattr(item, "type_id", None) or ( item.get("type_id") if isinstance(item, dict) else None ) - if str(type_id) != SPOIL_TYPE_ID: + if (catalog_row(str(type_id)) or {}).get("shape") != "spoil": # 표 형상(322-1) continue options = getattr(item, "options", None) if options is None and isinstance(item, dict): diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 90649e6fc..88607f211 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -24,21 +24,22 @@ 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 +from common_util.common_util_structure_catalog import base_type, catalog_row +from common_util.common_util_structure_station import EDGE_TOLERANCE_M logger = logging.getLogger(__name__) -# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. -_EDGE_TOLERANCE_M = 0.02 +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7) +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M -# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태는 벽 안 무늬만 정하므로 -# 가장 가까운 것으로 잇는다. -_FORM_BY_TYPE = { - "masonry_wet": "돌쌓기(찰)", - "masonry_dry": "돌쌓기(메)", - "boulder_masonry": "돌쌓기(메)", - "retaining_wall": "콘크리트", - "soil_guard": "통나무·목재틀", -} + +def _wall_form(type_id: str) -> str | None: + """종류 → 횡단 기하가 아는 **형태** 이름 = 구조물 표 형상칸.form(322-1 · TS `formOf` 짝). + + 형태는 벽 안 무늬만 정하므로 가장 가까운 것으로 잇는다(폼에서 형태를 비웠을 때). + """ + form = ((catalog_row(type_id) or {}).get("shapeOpts") or {}).get("form") + return form if isinstance(form, str) else None # 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`. @@ -58,7 +59,7 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]: return [ { "form": "콘크리트" - if type_id == "retaining_wall" + if base_type(type_id) == "retaining_wall" # 본보기 포함(323-7) else (options.get(f"tier_{n}_form") or None), "height_m": _num(options.get(f"tier_{n}_height")), } @@ -66,60 +67,60 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]: ] -# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). -BERM_TYPE_ID = "berm" - - def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: - """구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록.""" + """구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록. + + 소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9). + """ 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 {} - extras = tier_extras(structure.type_id, options) - 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"), - # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. - # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). - "foundation": options.get("foundation"), - # 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3) - "tiers": len(extras) + 1 if extras else options.get("tiers"), - "extras": extras, - "lift_m": options.get("lift_m"), - "shift_m": options.get("shift_m"), - # 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키). - "face_role": options.get("face_role"), - "face_slope_ratio": options.get("face_slope_ratio"), - } - ) - return found + return wall_specs_from(load_structures(str(project_root))[1]) except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)") return [] +def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]: + """구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4).""" + types = structure_type_map() + found: list[dict[str, Any]] = [] + for structure in structures: + definition = types.get(structure.type_id) + if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall": + continue + start, end = structure.start_m, structure.end_m + if start is None or end is None: + continue + options = structure.options or {} + extras = tier_extras(structure.type_id, options) + 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 _wall_form(structure.type_id), + "height_m": options.get("height_m"), + # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 + # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. + "side": options.get("side"), + # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. + # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). + "foundation": options.get("foundation"), + # 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3) + "tiers": len(extras) + 1 if extras else options.get("tiers"), + "extras": extras, + "lift_m": options.get("lift_m"), + "shift_m": options.get("shift_m"), + # 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키). + "face_role": options.get("face_role"), + "face_slope_ratio": options.get("face_slope_ratio"), + } + ) + return found + + def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None: """구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1). @@ -143,7 +144,7 @@ def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, fl try: spans: list[tuple[float, float, dict[str, float]]] = [] for item in load_structures(str(project_root))[1]: - if item.type_id != BERM_TYPE_ID: + if (catalog_row(item.type_id) or {}).get("shape") != "berm": # 표 형상(323-4) continue anchor = item.chainage_m if item.chainage_m is not None else item.start_m start = item.start_m if item.start_m is not None else anchor @@ -191,7 +192,14 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An 관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다. 한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫). """ - walls = load_wall_structures(project_root) + attached = attach_wall_specs(cross_sections, load_wall_structures(project_root)) + if attached: + logger.info("B06 구조물 벽 %d개 측점에 얹음", attached) + return attached + + +def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int: + """구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다.""" if not walls: return 0 attached = 0 @@ -210,6 +218,4 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An 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_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index 3fa30b969..6717e5d04 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -17,11 +17,12 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets +from common_util.common_util_structure_catalog import catalog_row +from common_util.common_util_structure_station import EDGE_TOLERANCE_M logger = logging.getLogger(__name__) -UNDERDRAIN_TYPE_ID = "cross_underdrain" -_EDGE_TOLERANCE_M = 0.02 +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M TiltRange = tuple[float, float, float | None] @@ -42,9 +43,14 @@ def load_underdrains(project_root: Path) -> list[dict[str, Any]]: except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)") return [] + return underdrains_from(structures) + + +def underdrains_from(structures: list[Any]) -> list[dict[str, Any]]: + """구조물 목록의 맹암거 제원(TS `underdrainSpecsFrom` 짝 · 형상 얹기 한 벌 323-4).""" found: list[dict[str, Any]] = [] for structure in structures: - if structure.type_id != UNDERDRAIN_TYPE_ID: + if (catalog_row(structure.type_id) or {}).get("shape") != "underdrain": continue options = structure.options or {} height = _number(options.get("culvert_height_m")) @@ -85,7 +91,13 @@ def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]: def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다.""" - drains = load_underdrains(project_root) + return attach_underdrain_specs(cross_sections, load_underdrains(project_root)) + + +def attach_underdrain_specs( + cross_sections: list[dict[str, Any]], drains: list[dict[str, Any]] +) -> int: + """기준 측점에 맹암거 제원을 얹는다(TS `attachUnderdrainSpecs` 짝). 얹은 개수를 돌려준다.""" attached = 0 for section in cross_sections: chainage = _number(section.get("chainage_m")) diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 7c9256058..f80a2a120 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -19,14 +19,13 @@ from B05_Profile.B05_Profile_Engine_Sections import ( 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_Attach import attach_saved_shapes from B06_Section.B06_Section_Engine_Design import ( compute_cross_design, curve_widening_args, stored_shoulder_extend, ) -from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures -from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges +from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, @@ -87,7 +86,6 @@ from B06_Section.B06_Section_Schema import ( from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for 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_structure_pavement import attach_pavement_structures 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, run_with_connection @@ -278,15 +276,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic isinstance(section, dict) for section in cross_sections ): raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.") - # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. - # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). - attach_culvert_sets(root, cross_sections) + # 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고) = 형상 얹기 한 벌(323-4 · 화면 짝 attachShapes) + attach_saved_shapes(root, cross_sections) relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4) - # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — - # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). - attach_wall_structures(root, cross_sections) - attach_pavement_structures(root, cross_sections) # 노면공 형상(88) - attach_underdrains(root, cross_sections) # 맹암거 형상(288-1) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -446,11 +438,8 @@ 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"]) - attach_pavement_structures(project_root, result["cross_sections"]) - attach_underdrains(project_root, result["cross_sections"]) + # 재생성 응답도 상세 조회와 같은 형상 얹기 한 벌(323-4) — 화면이 어긋나지 않게. + attach_saved_shapes(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 5923171c4..040d79122 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -24,6 +24,7 @@ from common_util.common_util_cross_berm import ( BermSpec, ) from common_util.common_util_route_profile import design_elevation_from_longitudinal +from common_util.common_util_structure_station import EDGE_TOLERANCE_M from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M logger = logging.getLogger(__name__) @@ -125,8 +126,8 @@ def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float: return 0.0 -# 범위 끝 허용오차(m) — 브라우저 짝(`common_util_structure_pavement` · `_culvert_sets`)과 같은 값. -_PAVED_EDGE_TOLERANCE_M = 0.02 +# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7) +_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool: diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 9956f49b5..f0e7fc6db 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -138,11 +138,12 @@ def _station_chainages(project_root: Path) -> list[float]: def _stale_marks(project_root: Path, route_data_path: str) -> int: """종단 정본의 구조물 측점과 **지금 관 · 구조물**이 어긋난 수 — 옮긴 관의 옛 자리 · 구조물이 - 떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272). 0 이면 맞음.""" + 떠난 격자 자리 · 구조물이 새로 얹혀야 할 격자 자리(272) · 같은 자리 다른 종류 · 이름(322-3 · + 관 공식 · 정의 판이 바뀐 이름 포함 — 이름이 지금 정의 판에서 옴). 0 이면 맞음.""" import json - planted = {round(value, 2) for value, _label in _planted(project_root, route_data_path)} - standing: set[float] = set() + planted = {(round(value, 2), label) for value, label in _planted(project_root, route_data_path)} + standing: set[tuple[float, str]] = set() for path in sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")): try: data = json.loads(path.read_text(encoding="utf-8")) @@ -150,7 +151,8 @@ def _stale_marks(project_root: Path, route_data_path: str) -> int: continue for station in data.get("stations") or []: if station.get("kind") == "irregular" or station.get("structure"): - standing.add(round(float(station.get("chainage_m") or 0.0), 2)) + at = round(float(station.get("chainage_m") or 0.0), 2) + standing.add((at, str(station.get("structure") or ""))) return len(standing ^ planted) diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 52854a601..633c9d7a9 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -7,8 +7,9 @@ * · 새 자리(서버 횡단 없음) = 서버가 미리 떠 준 횡단을 캐시에 얹음(`preview-stations` · 파일 · DB * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. * - * 심는 자리 규칙 = `plantedChainages`(서버 `resolve_extra_stations` 짝) · 카드 이름 = 서버 측점 이름 규칙 - * (`pipe_station_label` · 종류 이름) · 관 그림 = 짝 산식 `common_util_culvert_sets`. + * 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) · + * 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점 + * 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3). * ========================================================================== */ import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -20,16 +21,14 @@ import { type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; -import { - attachCulvertSets, - buildCulvertSets, - registryDefaults, -} from "@util/common_util_culvert_sets"; +import { registryDefaults } from "@util/common_util_culvert_sets"; +import { attachShapes } from "@util/common_util_structure_attach"; import { pipeStationMarks, - plantedChainages, + planStations, SAME_STATION_M, structureStationMarks, + type PlannedStation, } from "../common_util/common_util_structure_station"; import { previewStructureSections, @@ -103,54 +102,64 @@ async function previewOf( return chainages.flatMap((at) => previews.get(key(at)) ?? []); } -/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ -function decorate( - fresh: CrossSection[], - pipes: Pipes, - structures: StructureInstance[], - types: StructureType[], -): void { - const registry = registryDefaults(types); - const byId = new Map(types.map((type) => [type.type_id, type])); - const pipeLabel = (pipe: Pipes[number]): string | null => { - const facility = pipe.facility ?? "pipe"; - if (facility !== "pipe") return byId.get(facility)?.name ?? facility; - const values = { ...registry.pipe, ...pipe.options }; - return `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; - }; - const labels: Array<[number, string]> = [ - ...pipes.flatMap((pipe) => { - const label = pipeLabel(pipe); - return label - ? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]) - : []; - }), - ...structures.flatMap((structure) => { - const type = byId.get(structure.type_id); - const label = type?.name ?? structure.type_id; - return structureStationMarks(structure, type).map((m): [number, string] => [ - m.chainage_m, - label, - ]); - }), - ]; +/** 일반 측점 자리(grid · 272)의 「구조물 없는 꼴」(217) — 저장분에서 시설 · 이름만 뺀 사본(한 벌을 다시 씀 · + * 지형 쪽 고친 값은 그 측점 것 그대로) · 새 종류는 그 위에 얹음(`decorate`) · 측점은 남으므로 숨기지 않음. */ +const bares = new WeakMap(); +function bareOf(section: CrossSection): CrossSection { + let copy = bares.get(section); + if (!copy) { + copy = { ...section, preview: true }; + const record = copy as unknown as Record; + for (const key of ["culvert", "ford", "box", "ford_pavement", "structure"]) delete record[key]; + bares.set(section, copy); + } + return copy; +} + +/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */ +function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void { for (const section of fresh) { section.preview = true; - const hit = labels.find(([at]) => near(at, section.chainage_m)); - if (hit) section.structure = hit[1]; + // 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저) + const hit = planned.find((station) => near(station.chainage_m, section.chainage_m)); + if (hit) section.structure = hit.title; + else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217) } - const own = pipes.filter((pipe) => - fresh.some((section) => near(section.chainage_m, pipe.chainage_m)), - ); - // 미리 뜬 횡단은 한 벌을 기억해 다시 씀 — 얹기 전에 관 계열 제원을 비움(앞 종류 제원이 남아 - // 겹치던 것 · 서버가 얹어 온 저장분 종류도 · 열쇠 = `Section_Store` FACILITY_KEYS 와 같음 · 317-3) - for (const section of fresh as unknown as Array>) { - for (const key of ["culvert", "ford", "box", "ford_pavement"]) delete section[key]; - } - attachCulvertSets( - fresh as unknown as Array>, - buildCulvertSets(own, registry), - ); +} + +/** 현황 = 종류마다 초안 ?? 저장분 + 종류 표 — 측점 계획 · 형상 얹기가 같은 것(못 받으면 null). */ +function statusOf( + projectId: string, + pipesDraft: Pipes | null, + structuresDraft: StructureInstance[] | null, +): Promise<[Pipes, StructureInstance[], StructureType[]] | null> { + return Promise.all([ + pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes), + structuresDraft ?? savedOf(projectId).then(([, structures]) => structures), + fetchStructureTypes(true), + ]).catch(() => null); +} + +/** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */ +const drafted = new WeakSet(); + +/** + * 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 · + * 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로. + */ +export async function withDraftShapes( + detail: SectionDetailResponse, + projectId: string, +): Promise { + const pipesDraft = readPendingPipes(projectId); + const structuresDraft = readPendingStructures(projectId); + const drafts = !!(pipesDraft || structuresDraft); + if (!drafts && !drafted.has(detail)) return; + const inputs = await statusOf(projectId, pipesDraft, structuresDraft); + if (!inputs) return; + attachShapes(detail.cross_sections as unknown as Array>, ...inputs); + if (drafts) drafted.add(detail); + else drafted.delete(detail); } /** @@ -175,14 +184,11 @@ export async function withDraftStations( let stale: CrossSection[] = []; let fresh: CrossSection[] = []; if (pipesDraft || structuresDraft) { - const inputs = await Promise.all([ - pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes), - structuresDraft ?? savedOf(projectId).then(([, structures]) => structures), - fetchStructureTypes(true), - ]).catch(() => null); + const inputs = await statusOf(projectId, pipesDraft, structuresDraft); if (inputs) { const [pipes, structures, types] = inputs; - const planted = plantedChainages(pipes, structures, types); + const planned = planStations(pipes, structures, types, registryDefaults(types)); + const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 const [oldPipes, oldStructures] = await savedOf(projectId).catch( @@ -206,19 +212,31 @@ export async function withDraftStations( const was = kindsAt(oldPipes, oldStructures, section.chainage_m); return was !== "" && was !== kindsAt(pipes, structures, section.chainage_m); }; + // 일반 측점 자리(grid · 272)는 측점이 남음 — 종류가 바뀌거나 빠지면 구조물 없는 사본에 새 종류(217) const isStale = (section: CrossSection): boolean => section.kind === "irregular" && - !section.grid && - (planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); - stale = all.filter(isStale); - live = all.filter((section) => !isStale(section)); + (section.grid + ? replaced(section) + : planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); + // 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 · 형상(219) — + // 구조물 측점(irregular)은 위 종류 대조가 맡음 · 옛 것은 빼 둠(초안이 없어지면 돌아옴) + const retitled = all.filter( + (section) => + section.kind !== "irregular" && + planned.some((station) => near(station.chainage_m, section.chainage_m)), + ); + stale = [...all.filter(isStale), ...retitled]; + const bare = [...all.filter(isStale).filter((section) => section.grid), ...retitled].map( + bareOf, + ); + live = [...all.filter((section) => !stale.includes(section)), ...bare]; const missing = planted.filter( (at, index) => planted.findIndex((other) => near(other, at)) === index && live.every((section) => !near(section.chainage_m, at)), ); fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; - if (fresh.length) decorate(fresh, pipes, structures, types); + if (fresh.length || bare.length) decorate([...fresh, ...bare], planned); } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 6ec26a7ce..a752b2bce 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -18,34 +18,13 @@ * ========================================================================== */ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; -import { - fetchStructureTypes, - readPendingStructures, -} from "../B05_Profile/B05_Profile_Api_Structures"; -import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -import { - attachCulvertSets, - buildCulvertSets, - registryDefaults, -} from "@util/common_util_culvert_sets"; -import { - attachWallSpecs, - wallSpecsFrom, - type WallStructureInput, -} from "@util/common_util_structure_walls"; -import { - attachPavementSpecs, - pavementSpecsFrom, - type PavementStructureInput, -} from "@util/common_util_structure_pavement"; import { setGeneralStations } from "../common_util/common_util_structure_station"; import { - attachUnderdrainSpecs, - underdrainSpecsFrom, - type UnderdrainInput, -} from "../common_util/common_util_structure_underdrain"; -import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts"; + forgetDraftStations, + withDraftShapes, + withDraftStations, +} from "./B06_Section_Section_Drafts"; 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"; @@ -60,12 +39,11 @@ function keyOf(projectId: string, routeId: number): string { } /** - * 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다. + * 초안(관 · 구조물)을 상세에 얹는다 — **여기가 유일한 자리**다. * - * 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의 - * 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다. - * 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다 - * (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`). + * 서버는 저장분만 얹어 준다. 초안이 있으면 그것이 화면의 정본이므로, 캐시·세션·새 요청 어느 길로 + * 들어오든 이 함수를 지나 같은 상태가 된다(2026-09-06 실측). 측점 = 측점 계획 한 벌(`withDraftStations`) · + * 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` · 서버 `attach_shapes` 짝 · 323-4). */ async function withDraftWalls( detail: SectionDetailResponse, @@ -74,115 +52,10 @@ async function withDraftWalls( ): Promise { setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) - const drafts = readPendingStructures(projectId); - // 빈 목록 = 사용자가 다 지움 → 저장분 벽 · 노면 · 맹암거도 걷음(목록 `??` 규칙과 같음 · 312). - // 옛 「놓아도 안 보임」(09-09)은 옛 배열 참조가 `[]` 를 알리던 것 — 지금 `setStructures` 는 - // 배열 안을 채우고(갈아끼우지 않음) 알리지 않음 · 시험 `test_312_empty_pipes` 가 지킴. - await withDraftPipeKinds(detail, projectId); - if (!drafts) return withStructureAreas(detail); - // 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`) - attachUnderdrainSpecs( - detail.cross_sections as unknown as Array>, - underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]), - ); - const types = await fetchStructureTypes().catch(() => []); - // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) - const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id)); - if (paveTypes.size) { - for (const section of detail.cross_sections) delete section.pavement; - attachPavementSpecs( - detail.cross_sections as unknown as Array>, - pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes), - ); - } - 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), - ); + await withDraftShapes(detail, projectId); return withStructureAreas(detail); } -/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */ -const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; -const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => - facility === "ford_bridge" - ? "ford" - : facility === "box_culvert" - ? "box" - : facility === "ford_pavement" - ? "ford_pavement" - : "culvert"; - -/** - * 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다. - * 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식 - * (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다. - */ -async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { - const pipes = readPendingPipes(projectId); - if (!pipes) return; - const sections = detail.cross_sections as unknown as Array>; - const near = (spec: Record, section: Record, at: number) => - Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; - // 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314) - for (const section of sections) { - const spec = section.ford_pavement as Record | undefined; - const kept = (pipe: (typeof pipes)[number]) => - pipe.facility === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m); - if (spec && !pipes.some(kept)) delete section.ford_pavement; - } - /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ - const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => - key === facilityKey(pipe.facility ?? "pipe") && - (key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment")); - const stale = pipes.filter((pipe) => { - const here = sections.flatMap((section) => - FACILITY_KEYS.flatMap((key) => { - const spec = section[key] as Record | undefined; - return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; - }), - ); - // 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314) - if (!here.length) return pipe.facility === "ford_pavement"; - return !here.some(([key, spec]) => fits(pipe, key, spec)); - }); - if (!stale.length) return; - const types = await fetchStructureTypes().catch(() => []); - const registry = registryDefaults(types); - for (const section of sections) { - for (const key of FACILITY_KEYS) { - const spec = section[key] as Record | undefined; - if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key]; - } - // 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 그 밖 = 종류 이름) - // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점 - const pipe = stale.find((entry) => { - const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); - return section.structure ? gap < PIPE_MATCH_M : gap < 0.01; - }); - if (!pipe) continue; - const facility = pipe.facility ?? "pipe"; - const values = { ...registry.pipe, ...pipe.options }; - if (facility === "pipe") - section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; - else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility; - } - attachCulvertSets(sections, buildCulvertSets(stale, registry)); -} - /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * @@ -230,17 +103,6 @@ export async function loadSectionDetail( return request; } -/** 서버가 새 상세를 통째로 돌려준 조작(재생성 등) 뒤 캐시를 그 값으로 바꾼다. */ -export function replaceSectionDetail( - projectId: string, - routeId: number, - detail: SectionDetailResponse, -): void { - cache.set(keyOf(projectId, routeId), detail); - writeState("section-detail", detail, projectId, routeId); - setGeneralStations(detail.longitudinal?.stations); -} - /** * 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 — * 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다. diff --git a/B06_Section/B06_Section_UI_Cross_Axes.ts b/B06_Section/B06_Section_UI_Cross_Axes.ts index 72b8c20cf..2bb553ade 100644 --- a/B06_Section/B06_Section_UI_Cross_Axes.ts +++ b/B06_Section/B06_Section_UI_Cross_Axes.ts @@ -117,7 +117,8 @@ export function createCrossAxes( const yDecimals = Math.max(0, Math.ceil(-Math.log10(yStep) - 1e-9)); const screenYOf = (raw: number) => ty + scale * y(options.elevationMid + (raw - options.elevationMid) * options.exaggeration); - // 중심고 상자 자리 — 보이는 범위 안일 때만 · 그 높이 눈금 글자는 상자에 양보 + // 중심고 상자 자리 — 보이는 범위 안일 때만 · 겹친 눈금 글도 그림(상자를 맨 뒤에 붙여 위에 · + // 바탕이 안 비쳐 가림 · 319-1) const centerZ = options.centerZ; const centerY = typeof centerZ === "number" && centerZ >= rawBottom && centerZ <= rawTop @@ -134,7 +135,6 @@ export function createCrossAxes( class: "b06-chart__grid", }), ); - if (centerY !== null && Math.abs(screenY - centerY) < CENTER_BOX_H) continue; layer.append( svgText(raw.toFixed(yDecimals), { x: plotLeft - Y_LABEL_GAP_PX, diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index e044cebbb..84da01ef3 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -9,6 +9,8 @@ * 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다. * ========================================================================== */ +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { tagShape } from "../A00_Common/design/design_pick_match"; import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; @@ -219,6 +221,7 @@ export function appendBoxOverlay( event.stopPropagation(); onSelectSide(role); }); + tagShape(hit, box.chainage_m != null ? pipeIdAt(box.chainage_m) : null, role); // 신원표(323-5) hits.set(role, hit); layer.append(hit); } diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index e3e67923c..0fdf3e559 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -67,20 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> { `B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`, ]); } - // 세월교 · 박스 흙막이는 높이 빈 칸 = 본체 끝 높이(79-7)라 안내 안 함 - const own = section.culvert?.hidden_pipe && !section.culvert.body ? section.culvert : null; - if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) { + // 빈칸 경고 한 꼴(323-4) — 형상 필수 칸이 빈 관 · 구조물(관경 · 벽 · 기슭막이 높이 · 맹암거 높이 · 월류 + // 높이 · 형상 얹기가 측점 계획 자리에 얹음). 세월교 · 박스 흙막이 높이 빈 칸 = 본체 끝 높이(79-7)라 안 셈 + for (const item of section.blank ?? []) notes.push([ - "⚠ 기슭막이 높이 없음 — 근입만 그림", - "독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림", + `⚠ ${item} 빈칸`, + `${item}을 안 적어 그 그림을 안 그림(수치를 지어내지 않음 · 수량도 미확정) — 시설 칸에 적으면 그림`, ]); - } - if (section.ford_pavement && !section.ford_pavement.depth_m) { - notes.push([ - "⚠ 월류 높이 없음 — 파임 안 그림", - "물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림", - ]); - } return notes; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 226e9014b..325e00c3b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -10,6 +10,8 @@ * 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22) * ========================================================================== */ +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { tagShape } from "../A00_Common/design/design_pick_match"; import { REVET_EMBED_DEPTH_M, FILL_SLOPE_RATIO_MIN, @@ -88,6 +90,8 @@ export function appendCulvertOverlay( linked?: boolean, ): RevetHighlightSetter { const { culvert, pipe, pipeCorners } = layout; + // 신원표(323-5) — 관 세션 id · 부분 = 조정창 부재키 + const ownerId = culvert.chainage_m != null ? pipeIdAt(culvert.chainage_m) : null; const diameter = culvert.diameter_m; const roleLabel = (role: "inlet" | "outlet" | "extra") => role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)"; @@ -219,6 +223,7 @@ export function appendCulvertOverlay( ); if (onSelectRevet) revetShape.classList.add("is-selectable"); if (linked) revetShape.classList.add("is-linked"); + tagShape(revetShape, ownerId, wallKey); revetShapes.set(wallKey, revetShape); layer.append(revetShape); // 이음선 + 형태별 표현(돌쌓기 메/찰·콘크리트·돌망태·통나무·바자)은 공용 해칭이 @@ -242,6 +247,7 @@ export function appendCulvertOverlay( `유입 집수정(${shapeName}) ${part.kind === "floor" ? "바닥" : "벽"}` + ` — ${reasonText} · 내공 1.0m(실무 돌집수정) · 부재 두께 0.2m`, ); + tagShape(shape, ownerId, "inlet"); // 집수정 = 유입 부재(조정창 같은 키) basinShapes.push(shape); layer.append(shape); } @@ -345,6 +351,7 @@ export function appendCulvertOverlay( ]; if (!pipeHitPoints.length) pipeHitPoints = points; // 외경 = 판정면 const shape = polygon(points, cls, tip); + tagShape(shape, ownerId, "pipe"); pipeShapes.push(shape); layer.append(shape); } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 62082530c..f01e99057 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -9,6 +9,8 @@ * ④ 계류측 성토부선·절토선. 관 위 성토 채움은 그리지 않는다(2026-08-25 사용자). * ========================================================================== */ +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { tagShape } from "../A00_Common/design/design_pick_match"; import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; import { appendPipeInfoText, @@ -54,6 +56,7 @@ export function appendFordOverlay( onSelectWall?: (key: FordPick) => void, ): FordHighlightSetter { const { ford } = layout; + const ownerId = ford.chainage_m != null ? pipeIdAt(ford.chainage_m) : null; // 신원표(323-5) const toXY = (point: OffsetPoint): [number, number] => [ x(point.offset), toDisplayY(point.elevation), @@ -118,6 +121,7 @@ export function appendFordOverlay( onSelectWall(side.role); }); } + tagShape(shape, ownerId, side.role); shapes.push(shape); layer.append(shape); } @@ -133,6 +137,7 @@ export function appendFordOverlay( const pipeInner = polygon(layout.pipe.inner, "b06-chart__culvert-pipe", layout.label.text); layer.append(pipeOuter, pipeInner); const bodyShapes = [slab, pipeOuter, pipeInner]; + for (const shape of bodyShapes) tagShape(shape, ownerId, "body"); // 도수로 · 돌붙임 띠(90-2) — 유출 측벽 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림) if (ford.chute) diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 1f12fa437..7b1e747c0 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -14,6 +14,8 @@ import type { CrossDesign } from "./B06_Section_Api_Fetch"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface"; +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { tagShape } from "../A00_Common/design/design_pick_match"; import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch"; /** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */ @@ -117,8 +119,7 @@ export function appendFordPavementOverlay( .forEach((el) => { el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"); // 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글 - if (typeof spec.chainage_m === "number") - el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`); + if (typeof spec.chainage_m === "number") tagShape(el, pipeIdAt(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5) }); // 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2) const depth = spec.depth_m ?? 0; diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index db6536233..6c36b1dfe 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -16,6 +16,7 @@ * 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다. * ========================================================================== */ +import { tagShape } from "../A00_Common/design/design_pick_match"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { FILL_SLOPE_RATIO_MIN, @@ -25,6 +26,7 @@ import { WALL_MIN_PURE_HEIGHT_M, } from "./B06_Section_UI_Cross_Culvert_Const"; import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; @@ -89,6 +91,10 @@ export interface RevetmentLayout { heightM: number; /** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */ chainageM: number; + /** 벽 구조물 id — 그린 벽 신원표(323-5). */ + structureId: string | null; + /** 종류 이름(옹벽 · 흙막이 · 돌쌓기 …) — 툴팁 글(323-7 · 「독립 기슭막이」로 뜨던 것). */ + name: string; /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ @@ -277,7 +283,8 @@ export function computeRevetmentLayout( : { walls: [], segments: [], appliedAdjusts: [], addable: false }; const walls: WallLayout[] = [tier1, ...extras.walls]; - if (spec.type_id === "retaining_wall") for (const wall of walls) wall.retaining = true; + // 물빼기 파이프 단면 = 표 형상칸.weep(322-1 · 옹벽) + if (catalogRow(spec.type_id)?.shapeOpts.weep) for (const wall of walls) wall.retaining = true; const jt = tier1.topJoint; // 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고, // 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정). @@ -313,6 +320,8 @@ export function computeRevetmentLayout( side, heightM: pureHeight, chainageM: Number(section.chainage_m) || 0, + structureId: spec.structure_id ?? null, + name: spec.name || "벽", requestedTiers, top: jt, walls, @@ -346,7 +355,7 @@ export function appendRevetmentOverlay( segment.points, x, y, - `독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + + `${layout.name} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + (segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") + ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, ); @@ -360,7 +369,7 @@ export function appendRevetmentOverlay( layout.walls.forEach((wall, index) => { const keyId = index === 0 ? "own" : `own-extra${index - 1}`; const tooltip = - `독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + + `${layout.name} ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + `(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` @@ -374,6 +383,7 @@ export function appendRevetmentOverlay( const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId); if (onSelect) shape.classList.add("is-selectable"); if (layout.linked) shape.classList.add("is-linked"); + tagShape(shape, layout.structureId, keyId); // 신원표(323-5) drawn.push(shape); }); // 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를 diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts index 79e5a1cd0..5a0cb65cf 100644 --- a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -13,6 +13,8 @@ * ========================================================================== */ import type { RevetHighlightSetter } from "./B06_Section_UI_Cross_Culvert"; +import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids"; +import { paintPicked } from "../A00_Common/design/design_pick_match"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */ @@ -65,8 +67,11 @@ export function attachShapePick( layer: SVGElement, onPick?: (structureId: string) => void, ): ((active: boolean) => void) | null { + // 통째 형상(맹암거) · 물넘이 콘크리트만 — 조정창 부분(관 · 벽 · 날개벽 · 323-5 신원표)은 제 판정면이 있음 const shapes = [...layer.querySelectorAll("[data-structure-id]")].filter( - (el) => !el.hasAttribute("data-pave-type"), + (el) => + !el.hasAttribute("data-pave-type") && + [null, "concrete"].includes(el.getAttribute("data-part")), ); if (!shapes.length) return null; const kids = [...layer.children]; @@ -102,7 +107,8 @@ export function shapePickRoute( pickPipe: (chainageM: number) => void, ): (structureId: string) => void { return (structureId) => { - if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5))); + const pipeAt = pipeChainageOf(structureId); // 관 세션 id 장부(323-5) + if (pipeAt !== null) return pickPipe(pipeAt); from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId })); }; } @@ -121,12 +127,7 @@ export function splitPipePick( }; } -/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴 · 맹암거). 물넘이 - * 콘크리트(`pipe-…`)는 형상을 직접 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */ -export function highlightShapes(root: ParentNode, structureId: string | null): void { - for (const shape of root.querySelectorAll("[data-structure-id]")) { - const id = shape.getAttribute("data-structure-id") ?? ""; - if (id.startsWith("pipe-")) continue; - shape.classList.toggle("is-active", !!structureId && id === structureId); - } -} +/** 카드 안 형상 강조 — 고름 강조 한 규칙(`paintPicked` · 323-5) · 물넘이 콘크리트(관 부분)는 형상을 직접 + * 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */ +export const highlightShapes = (root: ParentNode, structureId: string | null): void => + paintPicked(root, { structureId }); diff --git a/B06_Section/B06_Section_UI_Page_Common.ts b/B06_Section/B06_Section_UI_Page_Common.ts index 97e343e27..5f4f7f01b 100644 --- a/B06_Section/B06_Section_UI_Page_Common.ts +++ b/B06_Section/B06_Section_UI_Page_Common.ts @@ -1,11 +1,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; -import { - fetchSectionDetail, - regenerateSections, - type SectionDetailResponse, -} from "./B06_Section_Api_Fetch"; -import { replaceSectionDetail } from "./B06_Section_Section_Store"; +import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { loadSectionDetail } from "./B06_Section_Section_Store"; export function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -38,10 +34,9 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) = showLoadingOverlay(); try { await regenerateSections(target.projectId, target.routeId, widthM); - // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다. - const fresh = await fetchSectionDetail(target.projectId, target.routeId); - // 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다. - replaceSectionDetail(target.projectId, target.routeId, fresh); + // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도 + // 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4) + const fresh = await loadSectionDetail(target.projectId, target.routeId, { force: true }); deps.applyDetail(fresh); showToast(L("B06_Profile_Regenerate_Success"), "success"); return true; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0ae24a0b0..3e870bbbc 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -48,6 +48,7 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. @@ -104,8 +105,8 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp writeState("berm", spans, projectId, routeId); } -/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ -export const BERM_TYPE_ID = "berm"; +/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */ +export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm"; /** * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 @@ -126,7 +127,7 @@ export function bermSpansFromStructures( ): BermSpan[] { const spans: BermSpan[] = []; for (const item of structures) { - if (item.type_id !== BERM_TYPE_ID) continue; + if (!isBermType(item.type_id)) continue; const anchor = item.chainage_m ?? item.start_m ?? null; const start = item.start_m ?? anchor; const end = item.end_m ?? anchor; @@ -380,6 +381,8 @@ export interface SectionPersistContext { * 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적 * (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */ reconcileDesigns: () => Promise; + /** 저장 뒤 새 상세로 카드 · 그림 한 번 다시(보일 때만 · 323-9). */ + redraw: () => Promise; } /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ @@ -498,12 +501,14 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave { ); clearState("kindreset", ctx.projectId); // 비울 자리를 보냈음(318-4) }, - done: () => { + done: async () => { // 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을 // 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다 // (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음). + // 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9) const routeId = ctx.routeId(); if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId); + await ctx.redraw(); }, }; } diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 48b66ddc8..c39fc5dbc 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -7,7 +7,6 @@ * ========================================================================== */ import { chute, guardSet, pipeDiameterM } from "@util/common_util_culvert_sets"; -import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; @@ -276,22 +275,3 @@ function applySoilTiers( if (Object.keys(clipped).length) queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped)); } - -/** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */ -const ADJUST_OWNED = /_revet_(height_m|form)$/; - -/** - * 관 초안 옵션을 **횡단 캐시에 다시 얹는다**(24-7a). 새로고침 뒤 캐시 상세는 저장분이라 - * 저장 전에 고친 월류 폭·관경 등이 폼·카드에서 옛 값으로 돌아간다 — 초안이 관 옵션의 정본이다. - */ -export function replayDraftPipeOptions( - ctx: PipeOptionsContext, - pipes: ReadonlyArray | null, -): void { - for (const pipe of pipes ?? []) { - const patch = Object.fromEntries( - Object.entries(pipe.options ?? {}).filter(([key]) => !ADJUST_OWNED.test(key)), - ); - if (Object.keys(patch).length) applyPipeOptionsToCache(ctx, pipe.chainage_m, patch, false); - } -} diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 390a81de5..7a6ebe3c7 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -280,10 +280,11 @@ export function createStationControls(deps: StationControlDeps): StationControls }, shapePicked: () => revetSelection?.shape === true, syncApplied: (chainageM, role, applied) => { - // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. + // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. 그리기는 조정값을 + // 새로 만들지 않음(323-6) — 사용자가 조작한 세션이 있을 때만 그 값을 깎음(없으면 자동 그대로 · 저장 안 함) const key = revetKey(chainageM, role); - const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST }; - if (sameAdjust(stored, applied)) return; + const stored = revetShifts.get(key); + if (!stored || sameAdjust(stored, applied)) return; storeAdjust(key, { ...applied }); }, update: (chainageM, role, patch) => { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 4cd5736e3..435cc2c02 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -20,7 +20,9 @@ import { } from "../A00_Common/design/design_shared"; import { fetchStructures, + dropStaleStructureTypes, fetchStructureTypes, + staleDefinitionNotice, readPendingStructures, pipesToStructureMarks, structureAnchorM, @@ -51,6 +53,7 @@ import { import { showToast } from "@ui/ui_template_elements"; import { snapStructureStation } from "../common_util/common_util_structure_station"; import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; +import { snapNotice, snapPlan } from "../B05_Profile/B05_Profile_UI_Structures_Snap"; import { adjustDockRoot, refreshAdjustSlots, @@ -59,6 +62,8 @@ import { } from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; +import { listPick } from "../common_util/common_util_structure_catalog"; +import { followMovedPipe, pipeIdAt } from "../A00_Common/design/design_pipe_ids"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; @@ -94,13 +99,15 @@ export interface B06StructuresPanelDeps { /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */ markStructure?: (structureId: string | null, span?: boolean) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ - onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; + /** `pipes` = 관 지점(구간 포함) — 종단 띠 측점 계획(322-3). */ + onMarks?: ( + structures: StructureInstance[], + types: StructureType[], + pipes: ReadonlyArray, + ) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => void; - /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** - * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ - onStructuresChanged?: () => void; /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ onPipesWritten?: () => void; } @@ -190,7 +197,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => - deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); + deps.onMarks?.( + [...structures, ...pipesToStructureMarks(pipeFacilities)], + markTypes, + pipeFacilities, + ); // 폼 한 벌(계획서 24-3b) — 횡단 보기가 보일 때 폼 알림을 받는 동작만 건다. 상세 옵션 칸은 // 한 벌이 보기에 따라 그린다(횡단 보기에서만). const section = sharedStructures(deps.shared); @@ -201,7 +212,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc section.setStructures(structures); pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); - deps.onStructuresChanged?.(); // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) const picked = readSelection(deps.projectId); const moved = picked?.structureId @@ -270,7 +280,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); showToast(PIPE_ADD_NOTICE, "success"); }, // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 @@ -291,7 +300,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (mine && JSON.stringify(linkFields(mine)) !== before) { persistPipes(); section.setPipeFacilities(pipeFacilities); - deps.onStructuresChanged?.(); } } // 폼 값은 **폼 칸 전부**다 — 비운 칸은 지운 것(계획서 24-4a · B05 [저장]과 같은 규칙). @@ -319,10 +327,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } persistPipes(); section.setPipeFacilities(pipeFacilities); - if (facility !== was) { - pushMarks(); - deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`) - } + // 카드 · 그림 다시 = 초안 구독(323-6 · `watchDrafts` — 종류가 바뀌면 그 관 자리 그림이 새 종류) + if (facility !== was) pushMarks(); }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { @@ -334,7 +340,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ); if (chainageM !== null) deps.focusChainage(chainageM); // 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2) - deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`); + deps.markStructure?.(chainageM === null ? null : pipeIdAt(chainageM, deps.projectId)); }, onListRelease: () => deps.releaseCard?.(), }, @@ -369,6 +375,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (!deps.projectId) return; const projectId = deps.projectId; try { + await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) const [types, stored, pipeResponse] = await Promise.all([ // B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서 // 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 @@ -377,10 +384,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc fetchStructures(projectId), fetchDetailPipePoints(projectId), ]); - section.setTypes(types); + section.setTypes(types, listPick("b06")); // 표 목록 both + b06(322-1) markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; + const note = staleDefinitionNotice(projectId, stored.definition_version); // 322-2 + if (note) showToast(note); section.setStructures(structures); const detail = deps.detail?.() ?? null; // 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며, @@ -411,6 +420,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc (basin) => Math.abs(basin.chainage_m - pipe.chainage_m) < PIPE_MATCH_M, )?.design_flow_m3s ?? null, })); + // 옛 소수 관 자리 = 정수 m 초안(요구 167 · B05 `snapOldStations` 와 같은 셈) — B06 을 바로 열어도 + // 카드 · 측점이 B05 를 거친 것과 같음 · 정본은 [저장]에서 + if (!pendingPipes) snapOldPipes(projectId); section.setPipeFacilities(pipeFacilities); // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. pushMarks(); @@ -484,6 +496,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc deps.onPipesWritten?.(); } + /** 옛 소수 관 자리 → 정수 m(확인 창 없이) — 계획고 편집 · 고름도 새 자리로(`movePipeTo` 와 같은 따라가기). */ + function snapOldPipes(projectId: string): void { + const plan = snapPlan([], pipeFacilities); + plan.pipes.forEach(({ from, to, attributes }) => { + const hit = pipeFacilities.find((pipe) => pipe.chainage_m === from); + if (!hit) return; + deps.shared.profile?.resetStationEdit(from, to); + followMovedPipe(projectId, from, to); + Object.assign(hit, { chainage_m: to, start_m: attributes.start_m, end_m: attributes.end_m }); + }); + if (plan.pipes.length) { + pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); + persistPipes(); + } + snapNotice(plan.pipes.length, plan.skipped); + } + /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ function movePipeTo(fromChainageM: number, toChainageM: number): void { if (!deps.projectId) { @@ -501,11 +530,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M) currentChainageM = to; + followMovedPipe(deps.projectId, fromChainageM, to); // 고른 관이면 고름도 새 자리로(322-3) pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2) showToast(PIPE_MOVE_NOTICE, "success"); } @@ -524,7 +553,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); showToast(PIPE_REMOVE_NOTICE, "success"); } @@ -611,7 +639,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc refreshSpecDefaults(hit.pipeChainageM); section.selectPipeByChainage(hit.pipeChainageM); // 알약은 폼 뒤 — 폼이 연 구조물을 닫으며 알약을 끄므로(165-2 · 측점선 없는 물넘이포장도 53-2) - deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); + deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId)); } else { openingAtStation = preferredId === null; try { @@ -627,7 +655,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, ); if (!pipe) return; - const id = `pipe-${pipe.chainage_m.toFixed(2)}`; + const id = pipeIdAt(pipe.chainage_m, deps.projectId); deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤 없음 — 옛 카드 · 측점선 풂(177-4 길 · 307) currentChainageM = pipe.chainage_m; refreshSpecDefaults(pipe.chainage_m); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index 954eb5130..810b76db7 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -10,7 +10,7 @@ import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel"; import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick"; -import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist"; +import { isBermType } from "./B06_Section_UI_Page_Persist"; /** * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 @@ -200,7 +200,7 @@ export function wireStructureSelection( .forEach((box) => box.classList.remove("is-active")); if (chainageM === null) return; const berm = panel.getStructures().find((item) => { - if (item.type_id !== BERM_TYPE_ID) return false; + if (!isBermType(item.type_id)) return false; const start = item.start_m ?? item.chainage_m ?? -1; const end = item.end_m ?? item.chainage_m ?? -1; return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6; diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 3250b8525..9fc75d028 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -9,6 +9,7 @@ import { PIPE_TYPES } from "@config/config_frontend"; import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations"; +import { pipeTitleKinds } from "../common_util/common_util_structure_catalog"; import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import type { DesignProfile, @@ -62,9 +63,12 @@ export interface YScaleOptions { * * 저장 라벨은 그대로 두어야 한다 — 재진입 이관(`B05_Profile_Structures_Migration`)이 라벨 * 문자열로 구조물 종류를 되짚는다. 그래서 바꾸는 것은 화면에 찍는 순간뿐이다. + * 관종 = 설정 관종 + 표 카드이름 pipe 종류의 정의 관종(322-1 · 흄관 · VR관도 「배수관」). */ export function structureDisplayName(label: string): string { - return PIPE_TYPES.some((kind) => label.startsWith(`${kind} D`)) ? PIPE_DISPLAY_NAME : label; + return [...PIPE_TYPES, ...pipeTitleKinds()].some((kind) => label.startsWith(`${kind} D`)) + ? PIPE_DISPLAY_NAME + : label; } export function validElevation( diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index ebe180f12..88a6567b7 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -642,11 +642,11 @@ export function createSectionView( setStructureEdit(edit) { structureEdit = edit; }, - setStructureMarks(structures, types) { + setStructureMarks(structures, types, pipes) { spanMarks = structures; spanTypes = types; syncSpanHighlights(); - profile.setMarks(structures, types); + profile.setMarks(structures, types, pipes); // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 // (`__corridorBuild` 등과 같은 용도). (window as unknown as { __b06Marks?: unknown }).__b06Marks = { diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index fab2266fb..08235c5ad 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -15,6 +15,7 @@ import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures"; import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; +import { listPick } from "../common_util/common_util_structure_catalog"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; /** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */ @@ -128,7 +129,7 @@ export function mountSectionStructureMenu( else input.edit.removeStructure(station.id); }, onAddPipe: (chainageM) => input.edit.addPipe(chainageM), - structureGroups: () => pickGroups(input.types, () => true), // B06 패널 거르기 = 전부(129-1) + structureGroups: () => pickGroups(input.types, listPick("b06")), // B06 패널 거르기와 같음(129-1 · 322-1) onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId), }); } diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index a6ece2772..b102316d9 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -47,6 +47,9 @@ import { moveMarkById, type SectionStructureEdit, } from "./B06_Section_UI_Section_View_Menu"; +import { planStations, type PlanPipe } from "../common_util/common_util_structure_station"; +import { plannedIrregular } from "../A00_Common/design/design_structure_status"; +import { withPipeIds } from "../A00_Common/design/design_pipe_ids"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ const MATCH_M = PIPE_MATCH_M; @@ -79,6 +82,7 @@ export interface SectionProfile { setMarks: ( structures: ReadonlyArray, types: ReadonlyArray, + pipes?: ReadonlyArray, ) => void; /** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */ redraw: () => void; @@ -88,25 +92,24 @@ export interface SectionProfile { selectMark: (structureId: string | null) => void; } -/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ -function pipeStations( +/** 종단 측점선 = 측점 계획 한 벌(322-3 · 관 · 측점 심는 구조물 모두 · 종단 보기 · 카드와 같은 목록). + * 관 구간(독립 기슭막이 시작 · 끝)은 받은 관 목록에서 · 없으면 알약에서 기준만. */ +function planMarkStations( marks: ReadonlyArray, + pipes: ReadonlyArray | null, types: ReadonlyArray, intervalM: number, ): IrregularStation[] { - return marks.filter(isPipeMark).map((mark) => { - const chainage = structureAnchorM(mark); - const station = Math.floor(chainage / intervalM); - return { - id: String(mark.structure_id), - station, - remainder: chainage - station * intervalM, - chainage_m: chainage, - structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관", - structureType: "배관", - origin: "pipe", - } as IrregularStation; - }); + const pipeList = + pipes ?? + marks.filter(isPipeMark).map((mark) => ({ + chainage_m: structureAnchorM(mark), + facility: mark.type_id, + options: mark.options, + })); + const structures = marks.filter((mark) => !isPipeMark(mark)); + // 덮는 구조물 id = 관 세션 id(323-5 · 알약 id 와 같음) + return plannedIrregular(planStations(withPipeIds(pipeList), structures, types), intervalM); } /** @@ -141,6 +144,7 @@ export function createSectionProfile( let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; + let markPipes: ReadonlyArray | null = null; /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ let markedId: string | null = null; let types: ReadonlyArray = []; @@ -180,7 +184,7 @@ export function createSectionProfile( if (!profile) return; profile.setStructureTypes([...types]); profile.setStructures([...marks]); - stations = pipeStations(marks, types, intervalM); + stations = planMarkStations(marks, markPipes, types, intervalM); profile.setIrregularStations(stations); // 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다. profile.setMinCoverPipes( @@ -302,9 +306,10 @@ export function createSectionProfile( } syncMarks(); }, - setMarks(nextMarks, nextTypes) { + setMarks(nextMarks, nextTypes, nextPipes) { marks = nextMarks; types = nextTypes; + markPipes = nextPipes ?? null; syncMarks(); }, redraw() { diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 5a96a7a4d..a4f4c7464 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -11,6 +11,7 @@ import type { } from "./B06_Section_Api_Fetch"; import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { PlanPipe } from "../common_util/common_util_structure_station"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; /** @@ -64,6 +65,8 @@ export interface SectionViewController { setStructureMarks: ( structures: ReadonlyArray, types: ReadonlyArray, + /** 관 지점(구간 포함) — 종단 띠 측점 계획(322-3 · 없으면 알약에서 기준만). */ + pipes?: ReadonlyArray, ) => void; /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ setStructureEdit: (edit: SectionStructureEdit | null) => void; diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2b23f1c65..a141ae442 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -395,7 +395,8 @@ html.is-bm-route .b06-section__notice-bar { font-size: 10px; } -/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 */ +/* 중심고 Y축 라벨(318-2) — 눈금과 같은 10px · 둥근 상자 · 테마 강조색 · 축선 앞에서 멈춤 · + 바탕 = 불투명 섞음(겹친 눈금 글을 위에서 가림 · 319-1) */ .b06-chart__center-box { fill: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface-raised)); stroke: var(--color-accent); diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py index 2d34ebbb1..d8f3038f4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -26,6 +26,7 @@ from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_stru from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points from common_util.common_util_node_bundle import run_bundle_json +from common_util.common_util_structure_station import PIPE_MATCH_M # 같은 자리 폭 한 곳(323-7) logger = logging.getLogger(__name__) @@ -33,8 +34,6 @@ ROOT = Path(__file__).resolve().parents[1] _OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js" _OPTIONS_NPM = "build:b07-note" -# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 -PIPE_MATCH_M = 0.51 _LAYER_ID = "기본" _FONT = 2.0 _LINE = 3.0 diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py new file mode 100644 index 000000000..90cde5083 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -0,0 +1,225 @@ +"""구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 반영 · 부품 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정. + +- 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것 +- 부품 = `{"갈래.부품": false}`(끈 것만) +- 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 · + 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면 + 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠) +- 앞 판이 없으면(옛 판 읽기) 꼴만 검사 +- 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로) +화면 짝 = `M02_SO_Def_Check.ts`(같은 글) +거울 시험 = `resources/tester/test_322_2_check_mirror.py`. +""" + +from __future__ import annotations + +import re +from typing import Any + +from common_util.common_util_structure_catalog import load_parts, resolve_catalog + +BRANCHES = ("횡단도", "카드이름", "형상", "3D") +# 화면 글(322-2 다듬기 · TS `M02_SO_Def_Reflect` 짝) — 자리꼴 · 정본 한국어 · 부품 짧은 이름 +PLACE_KO = {"point": "점", "interval": "구간", "site": "터"} +OWNER_KO = {"pipe_points": "관 지점", "structures": "구조물"} + + +def short_name(full: str) -> str: + """괄호 설명을 뗀 부품 이름(TS `shortName` 짝).""" + return re.sub(r"\s*\(.*\)\s*$", "", full) + + +OFF_VALUE = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"} +LISTS = ("both", "b06", "hidden") +_FIELDS = {"목록", "형상칸", *BRANCHES} + + +def _shape_errors(doc: dict[str, Any], known: set[str]) -> list[str]: + """반영 · 부품 꼴.""" + parts = load_parts()["parts"] + out: list[str] = [] + reflect = doc.get("반영", {}) + if not isinstance(reflect, dict): + out.append("반영은 {종류: 묶음} 이어야 함") + else: + for tid, row in reflect.items(): + if tid not in known or not isinstance(row, dict): + out.append(f"반영 「{tid}」 — 없는 종류이거나 묶음 아님") + continue + for field, value in row.items(): + where = f"반영 「{tid}.{field}」" + if field not in _FIELDS: + out.append(f"{where} — 모르는 칸") + elif field == "목록": + if value not in LISTS: + out.append(f"{where} — both · b06 · hidden 중 하나") + elif field == "형상칸": + ok = isinstance(value, dict) and all( + (isinstance(v, str) and bool(v.strip())) + if k == "form" + else (k == "weep" and isinstance(v, bool)) + for k, v in value.items() + ) + if not ok: + out.append(f"{where} — form 글 · weep 참거짓") + elif not isinstance(value, str) or ( + value != OFF_VALUE[field] and value not in parts[field] + ): + shown = value if isinstance(value, str) else _js(value) + out.append(f"{where} — 없는 부품 「{shown}」") + off = doc.get("부품", {}) + if not isinstance(off, dict): + out.append("부품은 {갈래.부품: 참거짓} 이어야 함") + else: + for key, value in off.items(): + branch, _, part = key.partition(".") + if part not in parts.get(branch, {}): + out.append(f"부품 「{key}」 — 없는 부품") + elif not isinstance(value, bool): + out.append(f"부품 「{key}」 — 참거짓") + return out + + +def _js(value: Any) -> str: + """TS `String(value)` 와 같은 글(검사 글 짝).""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + return ",".join(_js(v) for v in value) + if isinstance(value, dict): + return "[object Object]" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _hidden_in(doc: dict[str, Any] | None, tid: str) -> list[str]: + row = ((doc or {}).get("표시") or {}).get(tid) or {} + return [key for key, shown in row.items() if shown is False] + + +def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]: + """표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만).""" + parts = load_parts()["parts"] + rows = resolve_catalog(doc) + raw = resolve_catalog({**doc, "부품": {}}) # 부품 끄기 전 — 끈 탓 가름(323-9) + off_users: dict[str, list[str]] = {} # 끈 형상 부품 → 횡단도를 바꿔야 하는 종류 + out: list[str] = [] + for t in doc.get("types") or []: + row = rows.get(t["type_id"]) + if row is None: + continue + if row["shape"] == "none" and row["station"] != "none": + was = raw[t["type_id"]]["shape"] + if was != "none" and (doc.get("부품") or {}).get(f"형상.{was}") is False: + label = f"{row['groupName']} · {row['name']}" + off_users.setdefault(was, []).append(label) + else: + out.append(f"「{row['name']}」 형상이 「없음」이면 횡단도도 「안 만듦」") + # 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7) + kept = [k for tid in (t["type_id"], row["base"]) for k in _hidden_in(before or doc, tid)] + hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept] + # 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 + keys = { + o["key"] + for o in t.get("options") or [] + if o.get("default") is None and not o.get("empty_means") + } + used = ( + ("횡단도", row["station"]), + ("카드이름", row["title"]), + ("형상", row["shape"]), + ("3D", row["model3d"]), + ) + labels = {o["key"]: o.get("label") or o["key"] for o in t.get("options") or []} + for branch, part_id in used: + part = parts[branch].get(part_id) + if part is None: + continue + name = short_name(part["이름"]) + place = PLACE_KO.get(row["placement"], row["placement"]) + owner = OWNER_KO.get(row["owner"], row["owner"]) + if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]: + out.append(f"「{row['name']}」 {branch} 「{name}」 — {place} · {owner}에 안 맞음") + for key in part["읽는 칸"]: + if key in keys and key in hidden: + out.append( + f"「{row['name']}」 {branch} 「{name}」 이 쓰는 칸 「{labels[key]}」 — " + "숨길 수 없음" + ) + for part_id, names in off_users.items(): + name = short_name((parts["형상"].get(part_id) or {}).get("이름", part_id)) + out.append( + f"부품 「{name}」 끔 — 이 부품을 쓰는 종류({', '.join(names)})의 " + "횡단도를 「안 만듦」으로 바꿔야 저장" + ) + return out + + +def _template_errors(doc: dict[str, Any]) -> list[str]: + """본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 · + 정본(TS 짝).""" + out: list[str] = [] + kinds = doc.get("종류") or {} + now = {t["type_id"]: t for t in doc.get("types") or []} + for tid, row in kinds.items(): + src = (row or {}).get("본보기") if isinstance(row, dict) else None + if src is None or tid not in now: + continue + t, base = now[tid], now.get(src) + name = t.get("name") or tid + if base is None or src == tid or (kinds.get(src) or {}).get("본보기"): + out.append(f"「{name}」 본보기 「{src}」 — 원본 종류여야 함") + continue + same = all( + t.get(k) == base.get(k) for k in ("group", "placement", "managed_by") + ) and not base.get("managed_by") + if not same: + out.append( + f"「{name}」 본보기 「{base.get('name') or src}」 와 군 · 자리꼴 · 정본이 같아야 함" + ) + return out + + +def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: + """앞 판과 비교 — 만든 뒤 고정.""" + out: list[str] = [] + now = {t["type_id"]: t for t in doc.get("types") or []} + base_of = lambda d, tid: ((d.get("종류") or {}).get(tid) or {}).get("본보기") # noqa: E731 + for old in before.get("types") or []: + tid = old["type_id"] + name = old.get("name") or tid + t = now.get(tid) + if t is None: + out.append(f"종류 「{name}」 — 지울 수 없음") + continue + fixed = ( + ("자리꼴", old.get("placement"), t.get("placement")), + ("정본", old.get("managed_by"), t.get("managed_by")), + ("군 글자", old.get("group"), t.get("group")), + ("본보기", base_of(before, tid), base_of(doc, tid)), + ) + for what, a, b in fixed: + if a != b: + out.append(f"「{name}」 {what} — 만든 뒤 고정") + options = {o["key"]: o for o in t.get("options") or []} + for o in old.get("options") or []: + n = options.get(o["key"]) + if o.get("input") != "select" or n is None: + continue + if sorted(o.get("choices") or []) != sorted(n.get("choices") or []): + label = o.get("label") or o["key"] + out.append(f"「{name}」 「{label}」 선택지 값 — 고칠 수 없음(이름만 고침)") + return out + + +def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str]: + """구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 판(없으면 고정 검사 안 함).""" + known = {t["type_id"] for t in doc.get("types") or [] if isinstance(t, dict)} + shape = _shape_errors(doc, known) + if shape: + return shape + templates = _template_errors(doc) + return templates + (_row_errors(doc, before) + _fixed_errors(doc, before) if before else []) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 1529bacc3..3d6479ad2 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -1,8 +1,10 @@ /* ============================================================================= * M02_SO_Def.ts - * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 왼쪽 구조물군 · 종류 목록(이름 바꿈 · 순서 · 안 씀) · - * 오른쪽 고른 종류(이름 · 안 씀 · 기준 측점 표시 · 옵션 · 배치 격자 · 그룹 · 규칙) · - * 아래 [저장](판 +1 · 시스템 관리자만) · 지난 판 목록(누르면 그 판을 읽기만으로 봄). + * 구조물 정의 편집 화면(PLAN 48-2 · 메인 칸) — 탭 셋(322-2): 「한눈 표」(종류마다 반영 한 줄 · + * `M02_SO_Def_Table`) · 「종류별」(왼쪽 구조물군 · 종류 목록 · 오른쪽 고른 종류 이름 · 반영 · 명세 이름 나무 · + * 줄마다 보임 · 미리보기) · 「부품」(`M02_SO_Def_Parts`) · + * 아래 검사(화면 = 서버 짝 `M02_SO_Def_Check`) · [저장](판 +1 · 목록 + enabled 함께 · 시스템 관리자만) · + * 지난 판 목록(누르면 그 판을 읽기만으로 봄). * 다른 역할은 읽기만. 고칠 사본은 저장 전까지 서버에 안 감 · 정본 = `구조물정의.json`(48-1). * ========================================================================== */ @@ -27,8 +29,13 @@ import { import { buildOptionsBlock } from "./M02_SO_Def_Options"; import { buildRulesBlock } from "./M02_SO_Def_Rules"; import { buildSpecBlock } from "./M02_SO_Def_Spec"; -import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; -import { checkBox, textBox } from "./M02_SO_Def_Ui"; +import { specFor } from "../../A00_Common/design/design_structure_pipe"; +import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check"; +import { buildParts } from "./M02_SO_Def_Parts"; +import { differs, setReflect, tidy } from "./M02_SO_Def_Reflect"; +import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table"; +import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui"; +import { addFromTemplate, templates } from "./M02_SO_Def_Add"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; @@ -44,15 +51,6 @@ export interface DefScreenOptions { const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */ -function tidy(def: StructDef): StructDef { - const out = clone(def); - for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record[]) - for (const [k, v] of Object.entries(part)) - if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k]; - return out; -} - /** 작은 단추 */ function smallButton( label: string, @@ -88,6 +86,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { let current: string | null = null; const open = new Set(); // 선택지를 펼친 옵션 let versions: DefVersion[] | null = null; + type Tab = "table" | "kinds" | "parts"; + let tab: Tab = "table"; const shown = (): StructDef => past ?? work; const canEdit = (): boolean => opt.isAdmin && !past; @@ -100,8 +100,15 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { attrs: { "data-sod-history": "" }, }); + /** 검사 글 — 이름 · 배치 · 규칙(`problems`) + 구조물 표(서버 짝 · 앞 판 = 지금 서버 판) */ + const allProblems = (): string[] => + past ? [] : [...problems(work), ...catalogProblems(work as CheckDef, base as CheckDef)]; + const tabBar = el("div", { className: "m02-sod__tabs", attrs: { "data-sod-tabs": "" } }); + const body = el("div", { className: "m02-sod__body" }); + const panes = el("div", { className: "m02-opts__panes", children: [kindList, detail] }); + const changed = (): void => { - dirty = true; + dirty = differs(work, base); sync(); paint(); }; @@ -110,6 +117,21 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const d = shown(); const edit = canEdit(); const parts: HTMLElement[] = []; + // 종류 더하기(323-7) — 본보기 고름 + 단추 · 새 종류를 골라 둠(이름 · 반영은 오른쪽에서 고침) + if (edit) { + const pairs = templates(d); + let pick = pairs.find(([key]) => key === current)?.[0] ?? pairs[0]?.[0] ?? ""; + const box = pickBox(pairs, pick, (v) => (pick = v), { edit, attr: "data-sod-add-from" }); + box.title = to("AddKindNote"); + const add = smallButton(to("AddKind"), () => { + const key = addFromTemplate(d, pick); + if (!key) return; + current = key; + changed(); + }); + add.setAttribute("data-sod-add", ""); + parts.push(el("div", { className: "m02-sod__family", children: [box, add] })); + } const families = Object.keys(d.구조물군); families.forEach((g, gi) => { parts.push( @@ -188,7 +210,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const edit = canEdit(); const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); // 이름만 고침(PLAN 52-11 · 52-12) — 명세 종류 = 명세 나무 · 나머지 = 정의 그룹 나무 - const spec = STRUCTURE_SPECS[type.type_id]; + const spec = specFor(type.type_id); detail.replaceChildren( el("div", { className: "m02-sod__kindhead", @@ -199,10 +221,13 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { cls: "m02-sod__name", }), el("small", { className: "m02-opts__key", text: type.type_id }), - checkBox(to("Unused"), type.enabled === false, (v) => ((type.enabled = !v), changed()), { - edit, - attr: "data-sod-kind-off", - }), + // 안 씀 = 목록 B06 만(enabled 는 목록 짝 · 322-2) · 숨김(hidden)은 한눈 표 목록 칸에서 + checkBox( + to("Unused"), + type.enabled === false, + (v) => (setReflect(d, type.type_id, "목록", v ? "b06" : "both"), changed()), + { edit, attr: "data-sod-kind-off" }, + ), ...(!OLD_EDITOR ? [] : [ @@ -225,7 +250,10 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ], }), ...(!OLD_EDITOR - ? [buildSpecBlock({ def: d, type, spec, edit, changed })] + ? [ + buildReflectBlock(d, type, edit, changed), + buildSpecBlock({ def: d, base, type, spec, edit, changed }), + ] : [ buildOptionsBlock({ def: d, type, edit, open, changed }), buildLayoutBlock({ def: d, type, edit, changed }), @@ -235,6 +263,27 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ); }; + const paintTabs = (): void => { + const pairs: Array<[Tab, string]> = [ + ["table", to("TabTable")], + ["kinds", to("TabKinds")], + ["parts", to("TabParts")], + ]; + tabBar.replaceChildren( + ...pairs.map(([key, label]) => { + const b = createButton({ label, variant: key === tab ? "filled" : "ghost", size: "sm" }); + b.setAttribute("data-sod-tab", key); + b.addEventListener("click", () => ((tab = key), paint())); + return b; + }), + ); + const edit = canEdit(); + if (tab === "table") + body.replaceChildren(buildTable({ def: shown(), edit, changed, issues: allProblems() })); + else if (tab === "parts") body.replaceChildren(buildParts({ def: shown(), edit, changed })); + else body.replaceChildren(panes); + }; + const paintFoot = (): void => { banner.replaceChildren( ...(past @@ -248,7 +297,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { ]), ); banner.classList.toggle("is-past", !!past); - const list = past ? [] : problems(work); + const list = allProblems(); issues.replaceChildren(...list.map((t) => el("p", { className: "m02-sod__bad", text: t }))); save.disabled = !opt.isAdmin || !!past || !dirty || list.length > 0; }; @@ -256,6 +305,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const paint = (): void => { paintKinds(); paintDetail(); + paintTabs(); paintFoot(); }; @@ -273,7 +323,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const save = createButton({ label: to("Save"), variant: "filled" }); save.setAttribute("data-sod-save", ""); save.addEventListener("click", async () => { - if (problems(work).length) return; + if (allProblems().length) return; save.disabled = true; // 연달아 눌러도 한 번만 try { base = await saveStructureDef(base.판, tidy(work)); @@ -339,7 +389,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { children: [ el("p", { className: "m02-side__empty", text: to("DefNote") }), banner, - el("div", { className: "m02-opts__panes", children: [kindList, detail] }), + tabBar, + body, issues, el("div", { className: "m02-side__row", diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts new file mode 100644 index 000000000..ab0621376 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * M02_SO_Def_Add.ts + * 종류 더하기(PLAN 323-7) — 본보기(구조물 정본 원본 종류)를 그대로 복사한 새 종류. + * + * 키 자동(`{본보기}_xxxxxx` · 만든 뒤 안 바뀜) · 이름 「{본보기} 2」(겹치면 3 …) · 군 · 자리꼴 · 옵션 · 표시 · 그룹 · + * 배치 · 규칙 · 종류 덧칸(동작 · 이름 표) · 반영 그대로 · `종류.{새}.본보기 = 원본` — 구조물 표가 본보기 처음값을 + * 이어받고(`resolveCatalog` · 일곱 갈래 = 목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D · 자리꼴), 손코딩 동작 · + * 명세도 본보기 키로 고름(`baseType`). 관 지점 정본 종류는 본보기에서 뺌(관 시설 폼 · 정본 목록이 따로). + * ========================================================================== */ + +import { clone, newKey, type StructDef } from "./M02_SO_Def_Model"; + +/** 본보기로 고를 수 있는 종류 — 구조물 정본 · 원본(본보기 아님) · [키, 이름] (정의 차례). */ +export const templates = (def: StructDef): Array<[string, string]> => + def.types + .filter((t) => !t.managed_by && !def.종류[t.type_id]?.본보기) + .map((t) => [t.type_id, t.name] as [string, string]); + +/** 본보기를 복사해 그 뒤에 새 종류를 끼움 — 새 키(못 하면 null). */ +export function addFromTemplate(def: StructDef, sourceId: string): string | null { + const index = def.types.findIndex((t) => t.type_id === sourceId); + const src = def.types[index]; + if (!src || src.managed_by || def.종류[sourceId]?.본보기) return null; + const key = newKey( + `${sourceId}_`, + def.types.map((t) => t.type_id), + ); + const names = new Set(def.types.map((t) => t.name)); + let n = 2; + while (names.has(`${src.name} ${n}`)) n += 1; + const copy = clone(src); + copy.type_id = key; + copy.name = `${src.name} ${n}`; + delete copy.drawing_views; // 안 읽는 칸(323-7) + def.types.splice(index + 1, 0, copy); + def.종류[key] = { ...clone(def.종류[sourceId] ?? {}), 본보기: sourceId }; + for (const table of [def.표시, def.그룹, def.배치] as Array>) + if (table[sourceId] !== undefined) table[key] = clone(table[sourceId]); + def.규칙.push( + ...def.규칙.filter((r) => r.종류 === sourceId).map((r) => ({ ...clone(r), 종류: key })), + ); + const reflect = (def as { 반영?: Record }).반영; + if (reflect?.[sourceId] !== undefined) reflect[key] = clone(reflect[sourceId]); + return key; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts new file mode 100644 index 000000000..afbc38e80 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -0,0 +1,243 @@ +/* ============================================================================= + * M02_SO_Def_Check.ts + * 구조물 표 검사(PLAN 322-2 · 1-3 규칙) — 정의 `반영` · `부품` 꼴 · 표 줄과 부품이 맞음 · 만든 뒤 고정. + * + * · 반영 = `{종류: {목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D}}` · 값 = 부품 상자에 있는 것 + * · 부품 = `{"갈래.부품": false}`(끈 것만) + * · 표 줄(정의 ⊕ 처음값) — 형상 none 이면 횡단도도 none(218) · 부품 자리꼴 · 정본 = 그 종류 줄 · + * 부품이 읽는 칸 중 기본값 없는 칸(비워 두는 뜻 칸 빼고)은 새로 숨기지 못함(정의 「표시」 · 숨기면 + * 그림이 늘 기본값을 씀 · 앞 판에서 이미 숨긴 칸은 그대로 둠) + * · 앞 판이 없으면(옛 판 읽기) 꼴만 검사 + * · 앞 판과 비교 — 종류 지우기 없음 · 자리꼴 · 정본 · 군 글자 고정 · 선택지 값 고정(이름은 이름 표로) + * 서버 짝 = `M02_SO_Check.py`(같은 글) · 거울 시험 = `resources/tester/test_322_2_check_mirror.py`. + * ========================================================================== */ + +import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" }; +import { + resolveCatalog, + type CatalogDefinition, +} from "../../common_util/common_util_structure_catalog"; +import { OWNER_KO, PLACE_KO, shortName } from "./M02_SO_Def_Reflect"; + +type Branch = "횡단도" | "카드이름" | "형상" | "3D"; +interface Part { + 이름: string; + "읽는 칸": string[]; + 자리꼴: string[]; + 정본: string[]; +} +const PARTS_BY = (PARTS as unknown as { parts: Record> }).parts; + +export const BRANCHES: readonly Branch[] = ["횡단도", "카드이름", "형상", "3D"]; +/** 갈래마다 「없음」 값 — 카드이름은 종류 이름(name). */ +export const OFF_VALUE: Record = { + 횡단도: "none", + 카드이름: "name", + 형상: "none", + "3D": "none", +}; +export const LISTS = ["both", "b06", "hidden"] as const; +const FIELDS = new Set(["목록", "형상칸", ...BRANCHES]); + +/** 검사가 읽는 정의 칸. */ +export interface CheckDef extends CatalogDefinition { + types?: ReadonlyArray<{ + type_id: string; + name?: string; + group?: string; + placement?: string; + managed_by?: string | null; + options?: ReadonlyArray<{ + key: string; + input?: string; + choices?: readonly string[]; + default?: unknown; + empty_means?: string | null; + label?: string; + }>; + }>; + 표시?: Record>; +} + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); +/** 부품 상자에 있는 부품 — 제 칸만(물려받은 이름 아님). */ +const partOf = (branch: string, id: string): Part | undefined => { + const box = (PARTS_BY as Record>)[branch]; + return box && Object.hasOwn(box, id) ? box[id] : undefined; +}; + +/** 반영 · 부품 꼴. */ +function shapeErrors(doc: CheckDef, known: Set): string[] { + const out: string[] = []; + const reflect: unknown = doc.반영 ?? {}; + if (!isObject(reflect)) out.push("반영은 {종류: 묶음} 이어야 함"); + else + for (const [tid, row] of Object.entries(reflect)) { + if (!known.has(tid) || !isObject(row)) { + out.push(`반영 「${tid}」 — 없는 종류이거나 묶음 아님`); + continue; + } + for (const [field, value] of Object.entries(row)) { + const where = `반영 「${tid}.${field}」`; + if (!FIELDS.has(field)) out.push(`${where} — 모르는 칸`); + else if (field === "목록") { + if (!(LISTS as readonly unknown[]).includes(value)) + out.push(`${where} — both · b06 · hidden 중 하나`); + } else if (field === "형상칸") { + const ok = + isObject(value) && + Object.entries(value).every(([k, v]) => + k === "form" + ? typeof v === "string" && !!v.trim() + : k === "weep" && typeof v === "boolean", + ); + if (!ok) out.push(`${where} — form 글 · weep 참거짓`); + } else { + const branch = field as Branch; + if (typeof value !== "string" || (value !== OFF_VALUE[branch] && !partOf(branch, value))) + out.push(`${where} — 없는 부품 「${String(value)}」`); + } + } + } + const parts: unknown = doc.부품 ?? {}; + if (!isObject(parts)) out.push("부품은 {갈래.부품: 참거짓} 이어야 함"); + else + for (const [key, value] of Object.entries(parts)) { + const at = key.indexOf("."); + if (at < 0 || !partOf(key.slice(0, at), key.slice(at + 1))) + out.push(`부품 「${key}」 — 없는 부품`); + else if (typeof value !== "boolean") out.push(`부품 「${key}」 — 참거짓`); + } + return out; +} + +const hiddenIn = (doc: CheckDef | null, tid: string): string[] => + Object.entries(doc?.표시?.[tid] ?? {}) + .filter(([, shown]) => shown === false) + .map(([key]) => key); + +/** 표 줄 — 218 규칙 · 부품 자리꼴 · 정본 · 읽는 칸 보임(새로 숨김만). */ +function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { + const out: string[] = []; + const rows = resolveCatalog(doc); + const raw = resolveCatalog({ ...doc, 부품: {} }); // 부품 끄기 전 — 끈 탓 가름(323-9) + const offUsers = new Map(); // 끈 형상 부품 → 횡단도를 바꿔야 하는 종류 + for (const t of doc.types ?? []) { + const row = rows.get(t.type_id); + if (!row) continue; + if (row.shape === "none" && row.station !== "none") { + const was = raw.get(t.type_id)?.shape ?? "none"; + if (was !== "none" && doc.부품?.[`형상.${was}`] === false) + offUsers.set(was, [...(offUsers.get(was) ?? []), `${row.groupName} · ${row.name}`]); + else out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`); + } + // 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(323-7) + const kept = [t.type_id, row.base].flatMap((id) => hiddenIn(before ?? doc, id)); + const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key)); + // 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 + const keys = new Set( + (t.options ?? []) + .filter((o) => (o.default === null || o.default === undefined) && !o.empty_means) + .map((o) => o.key), + ); + const labelOf = (key: string): string => + (t.options ?? []).find((o) => o.key === key)?.label ?? key; + const used: Array<[Branch, string]> = [ + ["횡단도", row.station], + ["카드이름", row.title], + ["형상", row.shape], + ["3D", row.model3d], + ]; + for (const [branch, id] of used) { + const part = partOf(branch, id); + if (!part) continue; + const name = shortName(part.이름); + const place = PLACE_KO[row.placement] ?? row.placement; + const owner = OWNER_KO[row.owner] ?? row.owner; + if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner)) + out.push(`「${row.name}」 ${branch} 「${name}」 — ${place} · ${owner}에 안 맞음`); + for (const key of part["읽는 칸"]) + if (keys.has(key) && hidden.includes(key)) + out.push( + `「${row.name}」 ${branch} 「${name}」 이 쓰는 칸 「${labelOf(key)}」 — 숨길 수 없음`, + ); + } + } + offUsers.forEach((names, id) => + out.push( + `부품 「${shortName(partOf("형상", id)?.이름 ?? id)}」 끔 — 이 부품을 쓰는 종류(${names.join(", ")})의 ` + + "횡단도를 「안 만듦」으로 바꿔야 저장", + ), + ); + return out; +} + +/** 앞 판과 비교 — 만든 뒤 고정. */ +/** 본보기(복사한 종류 · 323-7) — 원본(구조물 정본 · 본보기 아닌 종류) · 같은 군 · 자리꼴 · 정본(파이썬 짝). */ +function templateErrors(doc: CheckDef): string[] { + const out: string[] = []; + const kinds = doc.종류 ?? {}; + const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); + for (const [tid, row] of Object.entries(kinds)) { + const src = row?.본보기; + const t = now.get(tid); + if (src == null || !t) continue; + const base = now.get(src); + const name = t.name || tid; + if (!base || src === tid || kinds[src]?.본보기) { + out.push(`「${name}」 본보기 「${src}」 — 원본 종류여야 함`); + continue; + } + const same = + (["group", "placement", "managed_by"] as const).every( + (k) => (t[k] ?? null) === (base[k] ?? null), + ) && !base.managed_by; + if (!same) + out.push(`「${name}」 본보기 「${base.name || src}」 와 군 · 자리꼴 · 정본이 같아야 함`); + } + return out; +} + +function fixedErrors(doc: CheckDef, before: CheckDef): string[] { + const out: string[] = []; + const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); + const baseOf = (d: CheckDef, tid: string) => d.종류?.[tid]?.본보기 ?? null; + for (const old of before.types ?? []) { + const t = now.get(old.type_id); + const name = old.name ?? old.type_id; + if (!t) { + out.push(`종류 「${name}」 — 지울 수 없음`); + continue; + } + const fixed: Array<[string, unknown, unknown]> = [ + ["자리꼴", old.placement, t.placement], + ["정본", old.managed_by ?? null, t.managed_by ?? null], + ["군 글자", old.group, t.group], + ["본보기", baseOf(before, old.type_id), baseOf(doc, old.type_id)], + ]; + for (const [what, a, b] of fixed) if (a !== b) out.push(`「${name}」 ${what} — 만든 뒤 고정`); + const options = new Map((t.options ?? []).map((o) => [o.key, o])); + for (const o of old.options ?? []) { + const n = options.get(o.key); + if (o.input !== "select" || !n) continue; + const a = [...(o.choices ?? [])].sort().join("\u0000"); + const b = [...(n.choices ?? [])].sort().join("\u0000"); + if (a !== b) + out.push(`「${name}」 「${o.label ?? o.key}」 선택지 값 — 고칠 수 없음(이름만 고침)`); + } + } + return out; +} + +/** 구조물 표 검사 — 틀린 것 글들(빈 목록 = 통과). `before` = 지금 서버 판(없으면 고정 검사 안 함). */ +export function catalogProblems(doc: CheckDef, before: CheckDef | null): string[] { + const known = new Set((doc.types ?? []).map((t) => t.type_id)); + const shape = shapeErrors(doc, known); + if (shape.length) return shape; + const templates = templateErrors(doc); + return [ + ...templates, + ...(before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []), + ]; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts index 2cb6355bb..b1bee5190 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -58,7 +58,11 @@ export interface StructDef { 동작: Record; types: TypeDef[]; /** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */ - 종류: Record }>; + /** 본보기 = 복사한 종류의 원본 키(323-7 · 만든 뒤 고정 · 처음값 · 손코딩 동작을 이어받음). */ + 종류: Record< + string, + { 기준측점?: boolean; 동작?: string[]; 이름?: Record; 본보기?: string } + >; 표시: Record>; 그룹: Record; 배치: Record>; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts new file mode 100644 index 000000000..7bc028558 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts @@ -0,0 +1,104 @@ +/* ============================================================================= + * M02_SO_Def_Parts.ts + * 구조물 정의 「부품」 탭(PLAN 322-2) — 코드가 그리는 방법(부품 상자 `common_util_structure_parts.json`). + * 갈래마다 부품 · 이름 · 읽는 칸 · 맞는 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 `부품` = 끈 것만). + * 끈 부품을 쓰는 종류는 그 갈래가 없음(카드 이름은 종류 이름)으로 — 표 줄(`resolveCatalog`)이 따름. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { StructDef } from "./M02_SO_Def_Model"; +import { + OWNER_KO, + PART_BOX, + partsOff, + PLACE_KO, + setPartOn, + shortName, + usersOf, +} from "./M02_SO_Def_Reflect"; +import { checkBox, head } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +type Branch = keyof typeof PART_BOX; + +export function buildParts(opt: { + def: StructDef; + edit: boolean; + changed: () => void; +}): HTMLElement { + const { def, edit, changed } = opt; + const off = partsOff(def); + /** 옵션 키 → 정의 이름(단위) — 그 부품을 쓰는 종류의 칸 이름 먼저 · 없으면 아무 종류 · 그래도 없으면 키. */ + const labelOf = (key: string, users: readonly string[]): string => { + const types = [ + ...def.types.filter((t) => users.includes(t.type_id)), + ...def.types.filter((t) => !users.includes(t.type_id)), + ]; + const o = types.flatMap((t) => t.options).find((x) => x.key === key); + return o ? (o.unit ? `${o.label} (${o.unit})` : o.label) : key; + }; + const blocks = (Object.keys(PART_BOX) as Branch[]).flatMap((branch) => [ + ...head(branch), + el("table", { + className: "m02-sod__table m02-sod__table--wrap", + attrs: { "data-sod-parts": branch }, + children: [ + el("thead", { + children: [ + el("tr", { + children: (["ColName", "PartRead", "PartFits", "PartUsers", "PartOn"] as const).map( + (key) => el("th", { text: to(key) }), + ), + }), + ], + }), + el("tbody", { + children: Object.entries(PART_BOX[branch]).map(([id, part]) => { + const key = `${branch}.${id}`; + // 끈 부품을 쓰던 종류도 보이게 — 끈 동안은 표가 none 으로 풀므로 켠 꼴로 셈 + const users = usersOf({ ...def, 부품: {} } as StructDef, branch, id); + return el("tr", { + attrs: { "data-sod-part": key }, + className: off[key] === false ? "is-off" : "", + children: [ + // 영어 키는 툴팁만(322-2 다듬기) — 이름 = 짧은 이름 · 읽는 칸 = 정의 칸 이름 + el("td", { text: shortName(part.이름), attrs: { title: `${id} · ${part.이름}` } }), + el("td", { + text: + part["읽는 칸"] + .map((k) => + labelOf( + k, + users.map((row) => row.typeId), + ), + ) + .join(" · ") || "—", + }), + el("td", { + text: `${part.자리꼴.map((p) => PLACE_KO[p] ?? p).join(" · ")} / ${part.정본 + .map((o) => OWNER_KO[o] ?? o) + .join(" · ")}`, + }), + el("td", { text: users.map((row) => row.name).join(" · ") || "—" }), + el("td", { + children: [ + checkBox( + to("PartOn"), + off[key] !== false, + (on) => (setPartOn(def, key, on), changed()), + { edit, attr: "data-sod-part-on" }, + ), + ], + }), + ], + }); + }), + }), + ], + }), + ]); + return el("div", { + className: "m02-sod__tablewrap", + children: [el("p", { className: "m02-side__empty", text: to("PartsNote") }), ...blocks], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts new file mode 100644 index 000000000..295f569df --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts @@ -0,0 +1,214 @@ +/* ============================================================================= + * M02_SO_Def_Reflect.ts + * 구조물 표 편집(PLAN 322-2)의 순수 손질 — 정의 사본의 `반영` · `부품` · 목록 + enabled · 형상칸. + * + * · 지금 값 = 구조물 표 한 줄(`resolveCatalog` · 정의 반영 ⊕ 처음값 ⊕ 부품 끔) + * · 반영에는 처음 값과 다른 칸만 남김(같으면 지움 = 「기본」) · [처음 값으로] = 그 종류 반영 지움 + * · 목록과 enabled 는 함께 씀 — enabled = 목록 both 일 때만 참(옛 짝 · 브레인 322-1 답 ①) + * 화면 그리기 없음 — 표 · 종류별 · 부품 탭이 이 함수들로 사본을 고침. + * ========================================================================== */ + +import PARTS from "../../common_util/common_util_structure_parts.json" with { type: "json" }; +import { + resolveCatalog, + type CatalogDefinition, + type CatalogRow, +} from "../../common_util/common_util_structure_catalog"; +import { clone, type StructDef } from "./M02_SO_Def_Model"; + +export type ReflectField = "목록" | "횡단도" | "카드이름" | "형상" | "3D"; +type Reflect = Partial> & { 형상칸?: Record }; +interface Part { + 이름: string; + "읽는 칸": string[]; + 자리꼴: string[]; + 정본: string[]; +} +type Branch = Exclude; + +export const PART_BOX = (PARTS as unknown as { parts: Record> }).parts; +const START = (PARTS as unknown as { 처음값: Record }).처음값; + +/** 화면 글(322-2 다듬기 · 영어 키 안 보임) — 자리꼴 · 정본 · 부품 짧은 이름(괄호 설명은 툴팁). */ +export const PLACE_KO: Record = { point: "점", interval: "구간", site: "터" }; +export const OWNER_KO: Record = { pipe_points: "관 지점", structures: "구조물" }; +export const shortName = (full: string): string => full.replace(/\s*\(.*\)\s*$/, ""); + +/** 칸 → 표 줄 열쇠. */ +const ROW_KEY: Record = { + 목록: "list", + 횡단도: "station", + 카드이름: "title", + 형상: "shape", + "3D": "model3d", +}; + +export const reflectOf = (def: StructDef): Record => + ((def as { 반영?: Record }).반영 ??= {}); +export const partsOff = (def: StructDef): Record => + ((def as { 부품?: Record }).부품 ??= {}); + +/** 사본 전체 표 — 종류마다 한 줄. */ +export const rowsOf = (def: StructDef): Map => + resolveCatalog(def as unknown as CatalogDefinition); + +/** 칸 지금 값. */ +export const valueOf = (row: CatalogRow, field: ReflectField): string => + String(row[ROW_KEY[field]]); + +/** 반영이 없을 때(처음 값 · 목록은 enabled 짝) 그 칸 값. */ +export function defaultOf(def: StructDef, typeId: string, field: ReflectField): string { + const copy = { ...def, 반영: { ...reflectOf(def) } } as unknown as StructDef; + const own = { ...(reflectOf(copy)[typeId] ?? {}) }; + delete own[field]; + reflectOf(copy)[typeId] = own; + const row = rowsOf(copy).get(typeId); + return row ? valueOf(row, field) : ""; +} + +/** 반영에 적힌 칸인가(아니면 「기본」). */ +export const isSet = (def: StructDef, typeId: string, field: ReflectField | "형상칸"): boolean => + field in (reflectOf(def)[typeId] ?? {}); + +const dropEmpty = (def: StructDef, typeId: string): void => { + const all = reflectOf(def); + if (all[typeId] && !Object.keys(all[typeId]).length) delete all[typeId]; +}; + +/** 칸 고침 — 처음 값과 같으면 반영에서 지움 · 목록이면 enabled 도(both 일 때만 참). */ +export function setReflect( + def: StructDef, + typeId: string, + field: ReflectField, + value: string, +): void { + const type = def.types.find((t) => t.type_id === typeId); + if (field === "목록" && type) type.enabled = value === "both"; + const own = (reflectOf(def)[typeId] ??= {}); + delete own[field]; + if (defaultOf(def, typeId, field) !== value) own[field] = value; + dropEmpty(def, typeId); +} + +/** 형상칸 한 칸 고침(form 글 · weep 참거짓) — 처음 값과 같으면 지움. */ +export function setShapeOpt(def: StructDef, typeId: string, key: string, value: unknown): void { + const own = (reflectOf(def)[typeId] ??= {}); + const opts = { ...(own.형상칸 ?? {}) }; + const start = START[typeId]?.형상칸?.[key]; + if (value === start || value === "" || (value === false && start === undefined)) delete opts[key]; + else opts[key] = value; + if (Object.keys(opts).length) own.형상칸 = opts; + else delete own.형상칸; + dropEmpty(def, typeId); +} + +/** [처음 값으로] — 그 종류 반영 지움 · enabled = 처음 목록 both 일 때만 참. */ +export function resetRow(def: StructDef, typeId: string): void { + delete reflectOf(def)[typeId]; + const type = def.types.find((t) => t.type_id === typeId); + if (type) type.enabled = START[typeId] ? START[typeId].목록 === "both" : type.enabled; +} + +/** [저장] 전 — 모든 종류 enabled = 지금 목록 both 일 때만 참(목록 + enabled 함께 씀). */ +export function syncEnabled(def: StructDef): void { + const rows = rowsOf(def); + for (const type of def.types) { + const row = rows.get(type.type_id); + if (row) type.enabled = row.list === "both"; + } +} + +/** 부품 켬끔 — 끔만 남김. */ +export function setPartOn(def: StructDef, key: string, on: boolean): void { + const off = partsOff(def); + if (on) delete off[key]; + else off[key] = false; +} + +/** 못 숨김 칸 — 종류가 지금 쓰는 부품들이 읽는 칸 중 기본값도 비워 두는 뜻도 없는 옵션 + * (숨기면 그림이 받을 값이 없음 · 검사 짝 `M02_SO_Def_Check`). */ +export function readKeys(def: StructDef, typeId: string): Set { + const row = rowsOf(def).get(typeId); + if (!row) return new Set(); + const type = def.types.find((t) => t.type_id === typeId); + const bare = new Set( + (type?.options ?? []) + .filter((o) => (o.default === null || o.default === undefined) && !o.empty_means) + .map((o) => o.key), + ); + const used: Array<[Branch, string]> = [ + ["횡단도", row.station], + ["카드이름", row.title], + ["형상", row.shape], + ["3D", row.model3d], + ]; + return new Set( + used + .flatMap(([branch, id]) => PART_BOX[branch][id]?.["읽는 칸"] ?? []) + .filter((key) => bare.has(key)), + ); +} + +/** 그 갈래 부품을 지금 쓰는 종류들. */ +export function usersOf(def: StructDef, branch: Branch, id: string): CatalogRow[] { + const field = ROW_KEY[branch]; + return [...rowsOf(def).values()].filter((row) => row[field] === id); +} + +/** 정의 「표시」 숨김 키(그 종류). */ +export const hiddenKeys = (def: StructDef, typeId: string): Set => + new Set( + Object.entries(def.표시[typeId] ?? {}) + .filter(([, shown]) => shown === false) + .map(([key]) => key), + ); + +/** 옵션 키들 보임 켬끔 — 정의 옵션인 키만(숨김 = false 만 남김). */ +export function setShown( + def: StructDef, + typeId: string, + keys: readonly string[], + on: boolean, +): void { + const type = def.types.find((t) => t.type_id === typeId); + const known = new Set((type?.options ?? []).map((o) => o.key)); + const row = (def.표시[typeId] ??= {}); + for (const key of keys.filter((k) => known.has(k))) { + if (on) delete row[key]; + else row[key] = false; + } + if (!Object.keys(row).length) delete def.표시[typeId]; +} + +/** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */ +export function tidy(def: StructDef): StructDef { + const out = clone(def); + syncEnabled(out); + const reflect = (out.반영 ?? {}) as Record; + for (const part of [out.그룹, out.배치, out.종류, out.표시, reflect] as Record[]) + for (const [k, v] of Object.entries(part)) + if (Array.isArray(v) ? !v.length : !Object.keys(v as object).length) delete part[k]; + return out; +} + +/** 열쇠 차례 무관 글 — 같은 값이면 같은 글. */ +const stable = (value: unknown): string => + Array.isArray(value) + ? `[${value.map(stable).join(",")}]` + : value && typeof value === "object" + ? `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${stable((value as Record)[k])}`) + .join(",")}}` + : JSON.stringify(value ?? null); + +/** 저장할 것이 있나 — 저장할 꼴(tidy)끼리 견줌 · 빈 반영 · 부품 칸은 없는 셈(바꿨다 되돌리면 꺼짐 · 322-2). */ +export function differs(work: StructDef, base: StructDef): boolean { + const canon = (def: StructDef): string => { + const out = tidy(def) as Record; + for (const key of ["반영", "부품"]) + if (out[key] && !Object.keys(out[key] as object).length) delete out[key]; + return stable(out); + }; + return canon(work) !== canon(base); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts index c3f5ab458..425295186 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -6,11 +6,13 @@ * 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품 * (`field` · `optionControl` · 그룹 상자)으로 같은 차례. * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. + * 줄마다 보임(322-2 · 정의 「표시」 · 컨테이너 = 그 안 칸 전부) · 부품이 읽는 칸 = 「못 숨김」(앞 판에서 + * 이미 숨긴 칸은 다시 보이기만) · 미리보기도 숨김을 따름. * ========================================================================== */ import { createTag, el } from "@ui/ui_template_elements"; import { createSpecForm } from "../../A00_Common/design/design_structure_form"; -import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import { specItemKeys, type StructureSpec } from "../../A00_Common/design/design_structure_spec"; import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; @@ -20,7 +22,8 @@ import { specNameRows, type SpecNameRow, } from "./M02_SO_Def_Spec_Model"; -import { head, textBox } from "./M02_SO_Def_Ui"; +import { hiddenKeys, readKeys, setShown } from "./M02_SO_Def_Reflect"; +import { checkBox, head, textBox } from "./M02_SO_Def_Ui"; import { to, type OptionsTextKey } from "./M02_SO_Text"; const TAG: Record, OptionsTextKey> = { @@ -36,7 +39,8 @@ const TAG: Record, OptionsTextKey> = { /** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */ function groupPreview(def: StructDef, type: TypeDef): HTMLElement { const names = def.종류[type.type_id]?.이름 ?? {}; - const options = type.options.filter((o) => o.enabled !== false); + const hidden = hiddenKeys(def, type.type_id); + const options = type.options.filter((o) => o.enabled !== false && !hidden.has(o.key)); const cell = (o: (typeof options)[number]): HTMLElement => field( o.unit ? `${o.label} (${o.unit})` : o.label, @@ -67,8 +71,45 @@ function groupPreview(def: StructDef, type: TypeDef): HTMLElement { }); } +/** 이름 줄이 쓰는 정의 옵션 키 — 명세 상자 = 그 안 칸 전부 · 칸 = 저장 · 갈래 키 · 그룹 = 그 옵션들. */ +function rowKeys(type: TypeDef, spec: StructureSpec | undefined, nameKey: string): string[] { + if (!nameKey || nameKey.includes("=")) return []; + if (!spec) { + if (!nameKey.startsWith("#")) return [nameKey]; + return []; + } + const [cid, iid] = nameKey.split("."); + const c = spec.containers.find((x) => x.id === cid); + const items = (c?.items ?? []).filter((i) => !iid || i.id === iid); + const own = new Set(type.options.map((o) => o.key)); + return items.flatMap(specItemKeys).filter((key) => own.has(key)); +} + +/** 줄 보임 칸 — 키 없는 줄은 비움 · 부품이 읽는 보이는 칸은 못 숨김. */ +function shownBox( + opt: { def: StructDef; base: StructDef; type: TypeDef; edit: boolean; changed: () => void }, + keys: string[], +): HTMLElement[] { + if (!keys.length) return []; + const { def, base, type, edit, changed } = opt; + const hidden = hiddenKeys(def, type.type_id); + const kept = hiddenKeys(base, type.type_id); + const read = readKeys(def, type.type_id); + const shown = keys.some((key) => !hidden.has(key)); + const locked = shown && keys.some((key) => read.has(key) && !kept.has(key)); + return [ + checkBox(to("ShownCol"), shown, (v) => (setShown(def, type.type_id, keys, v), changed()), { + edit: edit && !locked, + attr: "data-sod-spec-shown", + }), + ...(locked ? [createTag(to("CannotHide"), "neutral")] : []), + ]; +} + export function buildSpecBlock(opt: { def: StructDef; + /** 지금 서버 판 — 이미 숨긴 칸(못 숨김 예외)을 가름 */ + base: StructDef; type: TypeDef; /** 없으면 정의 그룹 꼴(52-12) */ spec: StructureSpec | undefined; @@ -90,6 +131,12 @@ export function buildSpecBlock(opt: { : el("span", { text: to("SpecLoose") }), el("small", { className: "m02-opts__key", text: row.key }), ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), + ...shownBox( + opt, + row.group && !spec + ? ((def.그룹[type.type_id] ?? []).find((g) => g.id === row.group)?.옵션 ?? []) + : rowKeys(type, spec, row.nameKey), + ), ], }), ); @@ -98,7 +145,14 @@ export function buildSpecBlock(opt: { if (spec) { const form = createSpecForm(null); const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); - form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); + form.write( + spec, + {}, + def.종류[type.type_id]?.이름 ?? {}, + groups, + {}, + hiddenKeys(def, type.type_id), + ); preview = form.root; } else preview = groupPreview(def, type); preview.inert = true; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts new file mode 100644 index 000000000..c5cd176b7 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts @@ -0,0 +1,333 @@ +/* ============================================================================= + * M02_SO_Def_Table.ts + * 구조물 정의 「한눈 표」 탭(PLAN 322-2) — 종류마다 B05 · B06 반영 한 줄. + * 군 · 종류(▲▼) · 이름 · 목록 · 자리꼴 · 정본(읽기만 「고정」) · 횡단도 · 카드 이름 · 형상(+ 형상칸) · + * 알약 색 · 3D · 검사. 흐린 칸 = 처음 값(「기본」) · 진한 칸 = 바꾼 값 · [처음 값으로] = 그 종류 반영 지움. + * 칸 손질 = `M02_SO_Def_Reflect` · 검사 글 = `M02_SO_Def_Check`(서버 짝) · 「종류별」 탭이 같은 칸(`reflectCells`)을 씀. + * ========================================================================== */ + +import { createButton, createTag, el } from "@ui/ui_template_elements"; +import type { CatalogRow } from "../../common_util/common_util_structure_catalog"; +import { stepFamily, stepType, type StructDef, type TypeDef } from "./M02_SO_Def_Model"; +import { REVET_FORMS } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; +import { + isSet, + OWNER_KO, + PART_BOX, + PLACE_KO, + shortName, + resetRow, + rowsOf, + setReflect, + setShapeOpt, + valueOf, + type ReflectField, +} from "./M02_SO_Def_Reflect"; +import { checkBox, pickBox, textBox } from "./M02_SO_Def_Ui"; +import { to } from "./M02_SO_Text"; + +type Pairs = ReadonlyArray; +const LIST_PAIRS = (): Pairs => [ + ["both", to("ListBoth")], + ["b06", to("ListB06")], + ["hidden", to("ListHidden")], +]; +/** 갈래 고르기 — 없음(횡단도 = 안 만듦 · 카드 이름 = 종류 이름) + 부품 상자 차례 · 글 = 짧은 이름 + * (영어 키 · 괄호 설명은 툴팁 · 322-2 다듬기). */ +const OFF_TEXT: Record, () => string> = { + 횡단도: () => to("StationNone"), + 카드이름: () => to("TitleName"), + 형상: () => to("OffNone"), + "3D": () => to("OffNone"), +}; +const partPairs = (branch: Exclude): Pairs => [ + [branch === "카드이름" ? "name" : "none", OFF_TEXT[branch]()], + ...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, shortName(part.이름)] as const), +]; +/** 고르기 항목 툴팁 = 키 · 부품 긴 이름. */ +function tipOptions(box: HTMLSelectElement, branch: ReflectField): void { + if (branch === "목록") return; + for (const option of Array.from(box.options)) { + const part = PART_BOX[branch][option.value]; + option.title = part ? `${option.value} · ${part.이름}` : option.value; + } +} + +interface CellCtx { + def: StructDef; + type: TypeDef; + row: CatalogRow; + edit: boolean; + changed: () => void; +} + +/** 반영 칸 하나 — 처음 값이면 흐리게 「기본」 · 바꿨으면 진하게. */ +function reflectCell(ctx: CellCtx, field: ReflectField): HTMLElement { + const { def, type, row, edit, changed } = ctx; + const pairs = field === "목록" ? LIST_PAIRS() : partPairs(field); + const box = pickBox( + pairs, + valueOf(row, field), + (v) => { + setReflect(def, type.type_id, field, v); + changed(); + }, + { edit, attr: `data-sod-reflect-${field}` }, + ); + tipOptions(box, field); + return mark(isSet(def, type.type_id, field), box); +} + +/** 흐림(기본) · 진함(바꿈) 꼴. */ +function mark(changed: boolean, ...children: HTMLElement[]): HTMLElement { + return el("div", { + className: `m02-sod__cell ${changed ? "is-changed" : "is-default"}`, + children: [...children, ...(changed ? [] : [createTag(to("Default"), "neutral")])], + }); +} + +/** 형상칸 — 벽(형상 wall)만: 기본 형태(그림 형태 고르기) · 물빼기. */ +function shapeOptsCell(ctx: CellCtx): HTMLElement { + const { def, type, row, edit, changed } = ctx; + if (row.shape !== "wall") return el("span", { className: "m02-side__empty", text: "—" }); + const form = String(row.shapeOpts.form ?? ""); + const forms = !form || REVET_FORMS.includes(form) ? REVET_FORMS : [form, ...REVET_FORMS]; + const box = pickBox( + forms.map((f) => [f, f] as const), + form, + (v) => (setShapeOpt(def, type.type_id, "form", v), changed()), + { edit, attr: "data-sod-shape-form" }, + ); + box.title = to("WallForm"); + return mark( + isSet(def, type.type_id, "형상칸"), + box, + checkBox( + to("Weep"), + row.shapeOpts.weep === true, + (v) => (setShapeOpt(def, type.type_id, "weep", v), changed()), + { edit, attr: "data-sod-shape-weep" }, + ), + ); +} + +/** 알약 색(style.color). */ +function colorCell(ctx: CellCtx): HTMLElement { + const box = el("input", { + className: "m02-sod__color", + attrs: { type: "color", "data-sod-color": "" }, + }) as HTMLInputElement; + const style = (ctx.type.style ?? {}) as { color?: string }; + box.value = style.color ?? "#888888"; + box.disabled = !ctx.edit; + box.addEventListener("change", () => { + ctx.type.style = { ...style, color: box.value }; + ctx.changed(); + }); + return box; +} + +/** 자리꼴 · 정본 = 한국어 글 · 「고정」(키는 툴팁). */ +const fixed = (text: string, key: string): HTMLElement => + el("span", { + className: "m02-sod__fixed", + attrs: { title: key }, + children: [el("span", { text }), createTag(to("Fixed"), "neutral")], + }); + +/** 「종류별」 탭이 쓰는 반영 칸들 — [글, 칸] 짝. */ +export function reflectCells( + def: StructDef, + type: TypeDef, + edit: boolean, + changed: () => void, +): Array<[string, HTMLElement]> { + const row = rowsOf(def).get(type.type_id); + if (!row) return []; + const ctx = { def, type, row, edit, changed }; + return [ + [to("ColList"), reflectCell(ctx, "목록")], + [to("ColPlacement"), fixed(PLACE_KO[row.placement] ?? row.placement, row.placement)], + [to("ColOwner"), fixed(OWNER_KO[row.owner] ?? row.owner, row.owner)], + [to("ColStation"), reflectCell(ctx, "횡단도")], + [to("ColTitle"), reflectCell(ctx, "카드이름")], + [to("ColShape"), reflectCell(ctx, "형상")], + [to("ColShapeOpts"), shapeOptsCell(ctx)], + [to("ColColor"), colorCell(ctx)], + [to("Col3D"), reflectCell(ctx, "3D")], + ]; +} + +function moveButton(label: string, run: () => boolean, off: boolean, changed: () => void) { + const b = createButton({ label, variant: "ghost", size: "sm" }); + b.disabled = off; + b.addEventListener("click", () => run() && changed()); + return b; +} + +/** 그 종류 검사 글 — 이름 · 키가 든 줄. */ +const issuesOf = (issues: readonly string[], type: TypeDef): string[] => + issues.filter((t) => t.includes(`「${type.name}」`) || t.includes(`「${type.type_id}`)); + +export function buildTable(opt: { + def: StructDef; + edit: boolean; + changed: () => void; + issues: readonly string[]; +}): HTMLElement { + const { def, edit, changed, issues } = opt; + const heads = [ + "ColFamily", + "ColKind", + "ColName", + "ColList", + "ColPlacement", + "ColOwner", + "ColStation", + "ColTitle", + "ColShape", + "ColShapeOpts", + "ColColor", + "Col3D", + "ColCheck", + ] as const; + const td = (child: HTMLElement | string): HTMLElement => + el("td", typeof child === "string" ? { text: child } : { children: [child] }); + const body: HTMLElement[] = []; + const families = Object.keys(def.구조물군); + families.forEach((g, gi) => { + const name = textBox(def.구조물군[g], (v) => (def.구조물군[g] = v), changed, { + edit, + attr: "data-sod-family-name", + }); + const familyRow = el("tr", { + className: "m02-sod__familyrow", + attrs: { "data-sod-table-family": g }, + children: [ + el("td", { + attrs: { colspan: String(heads.length) }, + children: [ + el("strong", { text: g }), + name, + ...(edit + ? [ + moveButton(to("GroupUp"), () => stepFamily(def, g, -1), gi === 0, changed), + moveButton( + to("GroupDown"), + () => stepFamily(def, g, 1), + gi === families.length - 1, + changed, + ), + ] + : []), + ], + }), + ], + }); + body.push(familyRow); + const kinds = def.types.filter((t) => t.group === g); + kinds.forEach((type, ti) => { + const cells = new Map(reflectCells(def, type, edit, changed)); + const bad = issuesOf(issues, type); + const reset = createButton({ label: to("Reset"), variant: "ghost", size: "sm" }); + reset.disabled = !edit || !(def as { 반영?: Record }).반영?.[type.type_id]; + reset.addEventListener("click", () => (resetRow(def, type.type_id), changed())); + body.push( + el("tr", { + attrs: { "data-sod-table-kind": type.type_id }, + children: [ + td(""), + el("td", { + attrs: { title: type.type_id }, // 영어 키는 툴팁만(322-2 다듬기) + children: [ + ...(edit + ? [ + moveButton( + to("GroupUp"), + () => stepType(def, type.type_id, -1), + ti === 0, + changed, + ), + moveButton( + to("GroupDown"), + () => stepType(def, type.type_id, 1), + ti === kinds.length - 1, + changed, + ), + ] + : []), + ], + }), + td( + textBox(type.name, (v) => (type.name = v), changed, { + edit, + attr: "data-sod-kind-name", + }), + ), + ...[ + "ColList", + "ColPlacement", + "ColOwner", + "ColStation", + "ColTitle", + "ColShape", + "ColShapeOpts", + "ColColor", + "Col3D", + ].map((key) => td(cells.get(to(key as (typeof heads)[number])) ?? "")), + el("td", { + attrs: { "data-sod-table-check": "" }, + children: [ + bad.length + ? el("span", { className: "m02-sod__bad", text: bad.join(" · ") }) + : createTag(to("Ok"), "accent"), + reset, + ], + }), + ], + }), + ); + }); + }); + return el("div", { + className: "m02-sod__tablewrap", + children: [ + el("p", { className: "m02-side__empty", text: to("TableNote") }), + el("table", { + className: "m02-sod__table", + attrs: { "data-sod-table": "" }, + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: to(h) })) })], + }), + el("tbody", { children: body }), + ], + }), + ], + }); +} + +/** 「종류별」 탭 머리 아래 반영 칸 묶음(한눈 표 한 줄과 같은 칸). */ +export function buildReflectBlock( + def: StructDef, + type: TypeDef, + edit: boolean, + changed: () => void, +): HTMLElement { + return el("div", { + className: "m02-sod__block", + attrs: { "data-sod-reflect-block": "" }, + children: [ + el("h4", { className: "m02-opts__group", text: to("ReflectHead") }), + el("div", { + className: "m02-sod__reflect", + children: reflectCells(def, type, edit, changed).map(([label, cell]) => + el("label", { + className: "m02-sod__reflectrow", + children: [el("span", { text: label }), cell], + }), + ), + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 439e0daf0..157e0eadf 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -18,10 +18,12 @@ from pydantic import ValidationError from B05_Profile import B05_Profile_Structures_Schema as schema from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors FILE = "구조물정의.json" HISTORY = "구조물정의_이력" -TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types"} +# 반영 · 부품 = 구조물 표 칸(322-2 · 없으면 처음값 · 검사 = `M02_SO_Check`) +TOP = {"판", "schema_version", "comment", "구조물군", "동작", "types", "반영", "부품"} SECTIONS = {"종류": dict, "표시": dict, "그룹": dict, "배치": dict, "규칙": list} SPOT = ("줄", "칸", "너비") @@ -64,7 +66,9 @@ def _type_errors(doc: dict[str, Any]) -> tuple[list[str], dict[str, dict[str, An groups = {} for row in doc.get("types") if isinstance(doc.get("types"), list) else []: try: - t = schema.StructureType.model_validate(row) + t = schema.StructureType.model_validate( + schema.without_retired(row) if isinstance(row, dict) else row + ) except ValidationError as e: errors.append( f"종류 「{row.get('type_id') if isinstance(row, dict) else row}」 꼴 — {e}" @@ -109,8 +113,11 @@ def _kind_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s continue if name == "이름" and _names_ok(value): continue + if name == "본보기" and isinstance(value, str) and value in keys: + continue # 복사한 종류의 원본(323-7) — 같은 군 등은 `_template_errors` errors.append( - f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · 이름 = 글 묶음)" + f"종류 덧칸 「{tid}.{name}」 이 틀림(기준측점 참거짓 · 동작 = 동작 이름들 · " + "이름 = 글 묶음 · 본보기 = 있는 종류 키)" ) return errors @@ -171,8 +178,8 @@ def _rule_errors(doc: dict[str, Any], keys: dict[str, dict[str, Any]]) -> list[s return errors -def check(doc: Any) -> list[str]: - """정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과).""" +def check(doc: Any, before: dict[str, Any] | None = None) -> list[str]: + """정의 한 벌 검사 — 틀린 것 목록(빈 목록 = 통과). `before` = 지금 판(만든 뒤 고정 · 322-2).""" from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups from M02_MasterTemplete.StructOptions import M02_SO_Options as options @@ -194,7 +201,8 @@ def check(doc: Any) -> list[str]: fn(doc[section], plain) except store.StoreError as e: errors += e.detail["errors"] if isinstance(e.detail, dict) else [str(e.detail)] - return errors + _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys) + errors += _kind_errors(doc, keys) + _spot_errors(doc, keys) + _rule_errors(doc, keys) + return errors + ([] if errors else catalog_errors(doc, before)) def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> dict[str, Any]: @@ -206,7 +214,7 @@ def _commit(change: Callable[[dict[str, Any]], dict[str, Any]], version: Any) -> if str(version) != str(have): raise store.StoreError(409, {"stale": [FILE], "판": have}) doc = {**change(current), "판": have} - errors = check(doc) + errors = check(doc, current) if errors: raise store.StoreError(422, {"errors": errors}) atomic_write_json(history_path(have), current) @@ -218,6 +226,13 @@ def save(doc: Any, version: Any) -> dict[str, Any]: """정의 통째로 고쳐 씀(시스템 관리자) — 돌려주는 것 = 새 정의.""" if not isinstance(doc, dict): raise store.StoreError(400, "정의는 묶음이어야 함") + # 옛 칸(drawing_views)은 [저장] 때 빠짐(323-7 · 아무도 안 읽음) + types = doc.get("types") + if isinstance(types, list): + doc = { + **doc, + "types": [schema.without_retired(t) if isinstance(t, dict) else t for t in types], + } return _commit(lambda _: dict(doc), version) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Router.py b/M02_MasterTemplete/StructOptions/M02_SO_Router.py index b18945fdb..448e0e4cc 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Router.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Router.py @@ -12,6 +12,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from B05_Profile.B05_Profile_Structures_Schema import definition_version from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -84,6 +85,13 @@ def get_structure_definition(session=Depends(verify_session)) -> dict: return _call(definition.read) +@read_router.get("/structure-definition/version") +def get_structure_definition_version(session=Depends(verify_session)) -> dict: + """지금 정의 판 `{판}` — B05 · B06 종류 캐시가 판을 볼 때(322-0 · 한 벌을 다 받지 않음).""" + access.require(session, "system", None) + return {"판": definition_version()} + + @read_router.get("/structure-definition/history") def get_structure_definition_history(session=Depends(verify_session)) -> list[dict]: """지난 판 목록 `[{판, 저장일}]`(새 판부터) — 로그인 누구나(PLAN 48-2).""" diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 233ecdc49..d654c0980 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -368,3 +368,79 @@ font-size: var(--text-caption); line-height: 1; } + +/* ── 322-2 탭 셋 · 한눈 표 · 부품 ── */ +.m02-sod__tabs { + display: flex; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} +.m02-sod__tablewrap { + overflow-x: auto; +} +.m02-sod__table { + border-collapse: collapse; + font-size: var(--text-body-sm); + min-width: 100%; +} +.m02-sod__table th, +.m02-sod__table td { + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-4); + text-align: left; + vertical-align: middle; + white-space: nowrap; +} +.m02-sod__familyrow td { + background: var(--color-surface-raised); +} +.m02-sod__table tr.is-off { + opacity: 0.5; +} +.m02-sod__cell { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); +} +.m02-sod__cell.is-default { + opacity: 0.6; +} +.m02-sod__cell.is-changed { + font-weight: 600; +} +.m02-sod__fixed { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + opacity: 0.7; +} +.m02-sod__color { + width: 32px; + height: 22px; + padding: 0; + border: none; + background: none; +} +.m02-sod__reflect { + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-4); +} +.m02-sod__reflectrow { + display: grid; + grid-template-columns: 7em 1fr; + align-items: center; + gap: var(--spacing-4); +} + +/* 부품 탭 — 긴 글은 줄바꿈해 표가 화면 안에(322-2 다듬기) */ +.m02-sod__table--wrap { + table-layout: fixed; + width: 100%; +} +.m02-sod__table--wrap td, +.m02-sod__table--wrap th { + white-space: normal; + word-break: keep-all; + overflow-wrap: anywhere; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index d56251b7b..a4ec6adbc 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -21,6 +21,11 @@ const TEXT = { GroupName: ["그룹 이름", "Group name"], GroupNew: ["새 그룹 {value}", "New group {value}"], GroupUp: ["위로", "Up"], + AddKind: ["종류 더하기", "Add type"], + AddKindNote: [ + "본보기 종류를 그대로 복사한 새 종류(이름 「본보기 2」) — 옵션 · 그룹 · 반영 · 그리는 법이 같음 · 이름 · 반영은 고쳐서 [저장]", + "New type copied from the template (name “template 2”) — same options, groups, reflect and drawing; edit name / reflect, then [Save]", + ], GroupDown: ["아래로", "Down"], GroupDel: ["지우기", "Delete"], GroupDelAsk: [ @@ -125,6 +130,49 @@ const TEXT = { Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"], Detail: ["B06 전용", "B06 only"], Save: ["저장", "Save"], + TabTable: ["한눈 표", "Overview"], + TabKinds: ["종류별", "By type"], + TabParts: ["부품", "Parts"], + TableNote: [ + "종류마다 B05 · B06 반영 한 줄 — 목록 · 횡단도 · 카드 이름 · 형상 · 3D 는 코드 부품 중에서 고름 · 흐린 칸 = 처음 값(기본) · 진한 칸 = 바꾼 값 · 자리꼴 · 정본은 만든 뒤 고정", + "One row per type — pick parts for list · cross section · card name · shape · 3D; faded = default, bold = changed", + ], + ColFamily: ["군", "Family"], + ColKind: ["종류", "Type"], + ColName: ["이름", "Name"], + ColList: ["목록", "List"], + ColPlacement: ["자리꼴", "Placement"], + ColOwner: ["정본", "Owner"], + ColStation: ["횡단도", "Cross section"], + ColTitle: ["카드 이름", "Card name"], + ColShape: ["형상", "Shape"], + ColShapeOpts: ["형상칸", "Shape fields"], + ColColor: ["알약 색", "Pill color"], + Col3D: ["3D", "3D"], + ColCheck: ["검사", "Check"], + ListBoth: ["B05 · B06", "B05 · B06"], + ListB06: ["B06 만", "B06 only"], + ListHidden: ["안 보임", "Hidden"], + OffNone: ["없음", "None"], + StationNone: ["안 만듦", "Not made"], + TitleName: ["종류 이름", "Type name"], + Default: ["기본", "Default"], + Fixed: ["고정", "Fixed"], + Reset: ["처음 값으로", "Reset"], + Ok: ["통과", "OK"], + WallForm: ["기본 형태", "Default form"], + Weep: ["물빼기", "Weep holes"], + ReflectHead: ["B05 · B06 반영", "B05 · B06 use"], + ShownCol: ["보임", "Shown"], + CannotHide: ["못 숨김", "Required"], + PartsNote: [ + "코드가 그리는 방법(부품) — 끄면 그 부품을 쓰는 종류는 그 갈래가 없음으로 · 읽는 칸 = 그 부품이 쓰는 옵션(숨기지 못함)", + "Code drawing parts — turning a part off makes its users fall back to none; read fields cannot be hidden", + ], + PartRead: ["읽는 칸", "Reads"], + PartFits: ["맞는 자리꼴 · 정본", "Fits"], + PartUsers: ["쓰는 종류", "Used by"], + PartOn: ["켬", "On"], Stale: [ "그 사이 정의가 바뀜 — 새로 받았으니 다시 고쳐 주세요", "Changed meanwhile — reloaded, please edit again", diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index ba1c749a7..09aecc0ec 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -11,8 +11,12 @@ * * 값을 새로 만들지 않는다 — **정본(관 지점 옵션) + 레지스트리 기본값**만 조합한다. * 상수 사본을 두면 B05 폼과 갈라지므로 기본값은 레지스트리에서만 꺼낸다. + * 시설마다 그릴 세트 = 구조물 표 형상(322-1 · `common_util_structure_catalog`). * ========================================================================== */ +// `.js` = 짝 거울 시험이 tsc → Node ESM 으로 이 파일만 엮음(확장자 있어야 찾음 · vite 는 .ts 로 풂) +import { catalogRow } from "./common_util_structure_catalog.js"; + /** 관 지점 정본 1건 — `pipe_points.json` 의 한 줄(브라우저는 API 로 같은 것을 받는다). */ export interface CulvertPipePoint { chainage_m: number; @@ -129,12 +133,8 @@ export function followPipeHeights( } } -/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */ +/** 시설 종류 기본값 — 파이썬 `common_util_drainage_pipes.PIPE_FACILITY_PIPE` 와 같은 문자열. */ 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 { @@ -566,14 +566,15 @@ export function buildCulvertSets( const chainage = num(point.chainage_m, null); if (chainage === null) continue; const values = (point.options ?? {}) as Record; - const facility = point.facility || FACILITY_PIPE; + // 그릴 세트 = 표 형상(322-1 · 모르는 시설 = 배관 세트) + const shape = catalogRow(point.facility || FACILITY_PIPE)?.shape ?? "pipe_set"; 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 if (text(values.pipe_diameter_mm) === null) - continue; // 관경 안 고름 = 관 없음(54-39) + if (shape === "ford") spec = fordSet(values, registry); + else if (shape === "box") spec = boxSet(values, registry); + else if (shape === "ford_pavement") spec = fordPavementSet(values, registry); + else if (shape === "hidden_revet") spec = revetSet(values); + else if (shape !== "pipe_set" || text(values.pipe_diameter_mm) === null) + continue; // 형상 없음 · 관경 안 고름 = 관 없음(54-39) else spec = culvertSet(values, registry); sets.set(round(chainage, 2), spec); } diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 1b8454b1c..144ea2fef 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -66,13 +66,13 @@ PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교 # 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를 # 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다. PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김) -_KNOWN_FACILITIES = ( - PIPE_FACILITY_PIPE, - PIPE_FACILITY_BOX, - PIPE_FACILITY_FORD_PAVEMENT, - PIPE_FACILITY_FORD_BRIDGE, - PIPE_FACILITY_REVET, -) + + +def _known_facility(facility: str) -> bool: + """관 지점 정본 종류인가 = 구조물 표 정본(323-7 · 옛 손 목록 걷음 · 모르는 종류 = 배관).""" + from common_util.common_util_structure_catalog import catalog_row + + return (catalog_row(facility) or {}).get("owner") == "pipe_points" @dataclass @@ -221,7 +221,8 @@ def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> if line.length <= 0: return points # 새 노선 위 자리도 정수 m · 일반 측점 1 m 안이면 그 측점(234-1) — 시설 속성은 관과 함께 옮겨 감 - stations = route_general_stations(vertices[-1].chainage_m) + # 끝 = 선 길이(B05 계획선 꼭짓점은 누가거리 0 으로 와서 끝이 0 → 관이 전부 0 m 로 가던 흠) + stations = route_general_stations(line.length) for point in points: if point.x is None or point.y is None: continue @@ -354,7 +355,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: PipePoint( chainage_m=float(chainage), source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER, - facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE, + facility=facility if _known_facility(facility) else PIPE_FACILITY_PIPE, start_m=start, end_m=end, x=float(raw_x) if isinstance(raw_x, (int, float)) else None, diff --git a/common_util/common_util_structure_attach.ts b/common_util/common_util_structure_attach.ts new file mode 100644 index 000000000..4aa882008 --- /dev/null +++ b/common_util/common_util_structure_attach.ts @@ -0,0 +1,124 @@ +/* ============================================================================= + * common_util/common_util_structure_attach.ts + * 형상 얹기 한 벌(323-4) — 측점(횡단) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료. + * + * 구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 서버와 같은 차례로 다시 얹음 — 관 세트 → 벽 → + * 포장 → 맹암거 → 빈칸 경고. 고르기 = 표 형상(322-1). 화면은 초안 ?? 저장분 현황으로 이것 하나를 씀 + * (`B06_Section_Section_Drafts.withDraftShapes`) — 저장 전 그림 = 저장 뒤 그림. + * 파이썬 짝 = `B06_Section/B06_Section_Engine_Attach.py` `attach_shapes`(서버 상세 읽기 · 재생성 응답) · + * 거울 시험 = `resources/tester/test_323_4_attach_mirror.py`. + * ========================================================================== */ + +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, + type CulvertPipePoint, +} from "./common_util_culvert_sets"; +import { + attachWallSpecs, + wallSpecsFrom, + type WallStructureInput, +} from "./common_util_structure_walls"; +import { + attachPavementSpecs, + pavementSpecsFrom, + type PavementStructureInput, +} from "./common_util_structure_pavement"; +import { + attachUnderdrainSpecs, + underdrainSpecsFrom, + type UnderdrainInput, +} from "./common_util_structure_underdrain"; +import { catalogRow, fieldName } from "./common_util_structure_catalog"; +import { + blankKeys, + planStations, + SAME_STATION_M, + type PlanPipe, + type PlanStructure, + type PlanType, +} from "./common_util_structure_station"; +import { SHAPE_KEYS } from "./common_util_structure_shape_keys"; + +export { SHAPE_KEYS }; + +export interface AttachType extends PlanType { + name: string; + options: ReadonlyArray<{ key: string; label?: string; default: unknown }>; +} + +/** 빈 필수 칸 → 「종류 이름 칸 이름」(칸 이름 = 그 종류 정의 → 다른 종류 정의 → 부품 상자 → 키). */ +function blankLabels( + typeId: string, + options: Record | null | undefined, + types: ReadonlyArray, +): string[] { + const name = catalogRow(typeId)?.name ?? typeId; + const ordered = [...types.filter((t) => t.type_id === typeId), ...types]; + return blankKeys(typeId, options).map((key) => { + const option = ordered.flatMap((t) => t.options).find((o) => o.key === key); + return `${name} ${option?.label ?? fieldName(key) ?? key}`; + }); +} + +/** 빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(파이썬 `_attach_blanks` 짝). */ +function attachBlanks( + sections: Array>, + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): void { + const owners = new Map | null | undefined]>(); + for (const pipe of pipes) + owners.set(pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`, [ + pipe.facility ?? "pipe", + pipe.options, + ]); + for (const item of structures) + if (item.structure_id) owners.set(item.structure_id, [item.type_id, item.options]); + for (const station of planStations(pipes, structures, types)) { + if (!station.blank.length) continue; + const labels = [ + ...new Set( + station.owners.flatMap((owner) => { + const hit = owners.get(owner); + return hit ? blankLabels(hit[0], hit[1], types) : []; + }), + ), + ]; + for (const section of sections) + if (Math.abs(Number(section.chainage_m) - station.chainage_m) < SAME_STATION_M) + section.blank = labels; + } +} + +/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */ +export function attachShapes( + sections: Array>, + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): void { + for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key]; + const registry = registryDefaults(types); + attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry)); + const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape); + // 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음 + attachWallSpecs( + sections, + wallSpecsFrom( + structures as unknown as WallStructureInput[], + new Map(shaped("wall").map((t) => [t.type_id, t.name] as const)), + ), + ); + attachPavementSpecs( + sections, + pavementSpecsFrom( + structures as unknown as PavementStructureInput[], + new Set(shaped("pavement").map((t) => t.type_id)), + ), + ); + attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[])); + attachBlanks(sections, pipes, structures, types); +} diff --git a/common_util/common_util_structure_catalog.py b/common_util/common_util_structure_catalog.py new file mode 100644 index 000000000..403168711 --- /dev/null +++ b/common_util/common_util_structure_catalog.py @@ -0,0 +1,146 @@ +"""구조물 표(catalog · PLAN 322-1) — 종류마다 「무엇을 할지」 한 줄. + +한 줄 = 정의 `반영` ⊕ 부품 상자 처음값 ⊕ 부품 끔. 종류 키 · 군 글자 · 자리꼴 · managed_by 로 +가리던 자리(측점 · 카드 이름 · 형상)가 이 한 줄을 읽음 · 그리는 함수는 그대로. +부품 상자 = `common_util_structure_parts.json`(TS 와 같은 파일) · 줄 열쇠도 TS 와 같은 이름. +TS 짝 = `common_util_structure_catalog.ts` +거울 시험 = `resources/tester/test_322_1_catalog_mirror.py`. +""" + +from __future__ import annotations + +import json +import math +from functools import lru_cache +from pathlib import Path +from typing import Any + +PARTS_PATH = Path(__file__).with_name("common_util_structure_parts.json") + +# 갈래 → 부품을 끈 값(`부품.{갈래}.{id} == false`) +_OFF = {"횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none"} + + +@lru_cache(maxsize=1) +def load_parts() -> dict[str, Any]: + with open(PARTS_PATH, encoding="utf-8") as handle: + return json.load(handle) + + +def resolve_catalog( + definition: dict[str, Any] | None, parts: dict[str, Any] | None = None +) -> dict[str, dict[str, Any]]: + """정의 + 부품 상자 → 종류마다 한 줄(정의 types 차례 · 정의가 없으면 처음값 차례).""" + start = (parts or load_parts())["처음값"] + definition = definition or {} + groups = definition.get("구조물군") or {} + group_keys = list(groups) + off = {key for key, on in (definition.get("부품") or {}).items() if on is False} + reflect = definition.get("반영") or {} + types = definition.get("types") or [{"type_id": key} for key in start] + rows: dict[str, dict[str, Any]] = {} + for order, item in enumerate(types): + type_id = item["type_id"] + root = ((definition.get("종류") or {}).get(type_id) or {}).get("본보기") or type_id + base = start.get(root) or {} # 복사한 종류 = 본보기 처음값(323-7) + mine = reflect.get(type_id) or {} + + def part(branch: str, base=base, mine=mine) -> str: + value = mine.get(branch, base.get(branch, _OFF[branch])) + return _OFF[branch] if f"{branch}.{value}" in off else value + + group = item.get("group") or "" + # 반영이 없으면 옛 짝 — 처음값 hidden 은 숨김 · 그 밖은 정의 enabled 가 가름(꺼짐 = B06 만) + if "목록" in mine: + listed = mine["목록"] + elif base.get("목록") == "hidden": + listed = "hidden" + else: + listed = "b06" if item.get("enabled") is False else "both" + rows[type_id] = { + "typeId": type_id, + "name": item.get("name") or type_id, + "group": group, + "groupName": groups.get(group, group), + "groupOrder": group_keys.index(group) if group in group_keys else len(group_keys), + "typeOrder": order, + "placement": base.get("자리꼴") or item.get("placement") or "point", + "owner": "pipe_points" if item.get("managed_by") == "pipe_points" else "structures", + "list": listed, + "station": part("횡단도"), + "title": part("카드이름"), + "shape": part("형상"), + "shapeOpts": {**(base.get("형상칸") or {}), **(mine.get("형상칸") or {})}, + "model3d": part("3D"), + "color": (item.get("style") or {}).get("color") or None, + "base": root, + } + return rows + + +@lru_cache(maxsize=4) +def _catalog_at(path: str, mtime: int, size: int) -> dict[str, dict[str, Any]]: + from B05_Profile.B05_Profile_Structures_Schema import _read_definition + + return resolve_catalog(_read_definition(path, mtime, size)) + + +def catalog() -> dict[str, dict[str, Any]]: + """지금 정의 판의 표 — 정의 파일이 바뀌면(M02 저장) 다음 부름부터 새 표(파일 시각 캐시).""" + from B05_Profile.B05_Profile_Structures_Schema import _stamp + + return _catalog_at(*_stamp()) + + +def catalog_row(type_id: str) -> dict[str, Any] | None: + return catalog().get(type_id) + + +def base_type(type_id: str) -> str: + """손코딩 동작 · 명세를 고를 종류 키 = 본보기(복사한 종류 · 323-7) — TS `baseType` 짝.""" + row = catalog_row(type_id) + return row["base"] if row else type_id + + +def type_ids_with(**match: str) -> set[str]: + """표에서 칸 값이 맞는 종류들 — 예 `type_ids_with(shape="pavement")`.""" + return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())} + + +def field_name(key: str) -> str | None: + """어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · TS `fieldName` 짝).""" + return (load_parts().get("칸 이름") or {}).get(key) + + +def shape_needs(type_id: str) -> list[str]: + """형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝.""" + row = catalog_row(type_id) + part = load_parts()["parts"]["형상"].get(row["shape"]) if row else None + return list(part.get("필수 칸", [])) if part else [] + + +def card_title(type_id: str, options: dict[str, Any] | None) -> str | None: + """카드 · 측점 이름 — 카드이름 pipe = 「관종 D관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 이름. + + 표에 없는 종류면 None(측점 안 붙임). TS `cardTitle` 짝(옛 빈칸 D800 ↔ 화면 D1000 어긋남 없앰). + """ + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + + row = catalog_row(type_id) + if row is None: + return None + if row["title"] != "pipe": + return row["name"] + definition = structure_type_map().get(type_id) + defaults = {o.key: o.default for o in definition.options} if definition else {} + values = options or {} + + def value(key: str) -> Any: + own = values.get(key) + return defaults.get(key) if own is None or own == "" else own + + try: + diameter = math.trunc(float(value("pipe_diameter_mm"))) + except (TypeError, ValueError): + diameter = 0 + return f"{value('pipe_kind') or ''} D{diameter}" diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts new file mode 100644 index 000000000..c8c67f05b --- /dev/null +++ b/common_util/common_util_structure_catalog.ts @@ -0,0 +1,206 @@ +/* ============================================================================= + * common_util/common_util_structure_catalog.ts + * 구조물 표(catalog · PLAN 322-1) — 종류마다 「무엇을 할지」 한 줄 = 정의 `반영` ⊕ 부품 상자 처음값 ⊕ 부품 끔. + * + * 종류 키 · 군 글자 · 자리꼴 · managed_by 로 가리던 자리(목록 · 측점 · 카드 이름 · 형상 · 3D · 알약)가 이 한 줄을 + * 읽음. 그리는 함수는 그대로(그림 자료 열쇠만 봄). 부품 상자 = `common_util_structure_parts.json`(파이썬과 같은 파일). + * 정의를 받기 전 = 처음값만으로 선 표(이름 · 군 · 정본 · 색 없음) — 받으면 `useCatalogDefinition` 이 갈아 끼움. + * 파이썬 짝 = `common_util_structure_catalog.py` · 거울 시험 = `resources/tester/test_322_1_catalog_mirror.py`. + * ========================================================================== */ + +import PARTS from "./common_util_structure_parts.json" with { type: "json" }; + +/** 고르기 목록 자리 — both = B05 · B06 · b06 = B06 만 · hidden = 안 보임(옛 자료는 그대로 그림). */ +export type ListPlace = "both" | "b06" | "hidden"; +export type PipeOwner = "pipe_points" | "structures"; + +export interface CatalogRow { + typeId: string; + name: string; + group: string; + groupName: string; + groupOrder: number; + typeOrder: number; + placement: string; + owner: PipeOwner; + list: ListPlace; + /** 횡단도 부품 — none · anchor · span3. */ + station: string; + /** 카드 이름 부품 — name · pipe. */ + title: string; + /** 형상 부품 — none · pipe_set · box · ford · ford_pavement · hidden_revet · wall · berm · underdrain · pavement · spoil. */ + shape: string; + shapeOpts: Record; + /** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */ + model3d: string; + color: string | null; + /** 본보기 종류 키(323-7 · M02 종류 더하기) — 처음값 · 손코딩 동작을 이 종류에서 이어받음 · 원본이면 자기. */ + base: string; +} + +type Branch = "횡단도" | "카드이름" | "형상" | "3D"; +type Reflect = Partial> & { + 형상칸?: Record; + 자리꼴?: string; +}; + +/** 정의 한 벌 중 표가 읽는 칸 — `반영` · `부품` 은 M02 [저장] 때 생김(없으면 처음값). */ +export interface CatalogDefinition { + 구조물군?: Record; + types?: ReadonlyArray<{ + type_id: string; + name?: string; + group?: string; + placement?: string; + managed_by?: string | null; + enabled?: boolean; + style?: { color?: string }; + options?: ReadonlyArray<{ key: string; choices?: readonly string[] }>; + }>; + 반영?: Record; + 부품?: Record; + /** 종류 덧칸 — 본보기(복사한 종류의 원본 키 · 323-7). */ + 종류?: Record; +} + +interface Parts { + parts: Record>; + 처음값: Record; +} + +/** 갈래 → 부품을 끈 값(`부품.{갈래}.{id} === false`). */ +const OFF: Record = { + 횡단도: "none", + 카드이름: "name", + 형상: "none", + "3D": "none", +}; + +/** 정의 + 부품 상자 → 종류마다 한 줄(정의 types 차례 · 정의가 없으면 처음값 차례). */ +export function resolveCatalog( + definition: CatalogDefinition | null, + parts: Parts = PARTS as unknown as Parts, +): Map { + const start = parts.처음값; + const groups = definition?.구조물군 ?? {}; + const groupKeys = Object.keys(groups); + const off = new Set( + Object.entries(definition?.부품 ?? {}) + .filter(([, on]) => on === false) + .map(([id]) => id), + ); + const types = definition?.types?.length + ? definition.types + : Object.keys(start).map((type_id) => ({ type_id }) as const); + const rows = new Map(); + types.forEach((item, typeOrder) => { + const id = item.type_id; + const root = definition?.종류?.[id]?.본보기 ?? id; // 복사한 종류 = 본보기 처음값(323-7) + const base = start[root] ?? {}; + const mine = definition?.반영?.[id] ?? {}; + const part = (branch: Branch): string => { + const value = mine[branch] ?? base[branch] ?? OFF[branch]; + return off.has(`${branch}.${value}`) ? OFF[branch] : value; + }; + const group = "group" in item ? (item.group ?? "") : ""; + const index = groupKeys.indexOf(group); + rows.set(id, { + typeId: id, + name: ("name" in item && item.name) || id, + group, + groupName: groups[group] ?? group, + groupOrder: index < 0 ? groupKeys.length : index, + typeOrder, + placement: base.자리꼴 ?? ("placement" in item ? item.placement : undefined) ?? "point", + owner: + "managed_by" in item && item.managed_by === "pipe_points" ? "pipe_points" : "structures", + // 반영이 없으면 옛 짝 — 처음값 hidden 은 숨김 · 그 밖은 정의 enabled 가 가름(꺼짐 = B06 만) + list: (mine.목록 ?? + (base.목록 === "hidden" + ? "hidden" + : "enabled" in item && item.enabled === false + ? "b06" + : "both")) as ListPlace, + station: part("횡단도"), + title: part("카드이름"), + shape: part("형상"), + shapeOpts: { ...(base.형상칸 ?? {}), ...(mine.형상칸 ?? {}) }, + model3d: part("3D"), + color: ("style" in item && item.style?.color) || null, + base: root, + }); + }); + return rows; +} + +let current = resolveCatalog(null); +let pipeKinds: string[] = []; + +/** 정의를 받으면(종류 레지스트리 · `fetchStructureTypes`) 표를 갈아 끼움 — 못 받았으면 처음값 표 그대로. */ +export function useCatalogDefinition(definition: CatalogDefinition | null): void { + current = resolveCatalog(definition); + pipeKinds = [ + ...new Set( + (definition?.types ?? []) + .filter((type) => current.get(type.type_id)?.title === "pipe") + .flatMap( + (type) => type.options?.find((option) => option.key === "pipe_kind")?.choices ?? [], + ), + ), + ]; +} + +/** 종류 한 줄 — 표에 없는 종류면 undefined. */ +export const catalogRow = (typeId: string): CatalogRow | undefined => current.get(typeId); + +/** 손코딩 동작 · 명세를 고를 종류 키 = 본보기(복사한 종류 · 323-7) · 원본이면 자기 · 파이썬 `base_type` 짝. */ +export const baseType = (typeId: string): string => current.get(typeId)?.base ?? typeId; + +/** 고르기 목록에 보일 종류인가 — B05 = both · B06 = both + b06(표에 없는 종류는 보임). */ +export const listPick = + (page: "b05" | "b06") => + (type: { type_id: string }): boolean => { + const list = catalogRow(type.type_id)?.list ?? "both"; + return list === "both" || (page === "b06" && list === "b06"); + }; + +/** 카드 · 측점 이름 — 카드이름 pipe = 「관종 D관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 종류 이름. */ +export function cardTitle( + typeId: string, + options: Record | null | undefined, + defaults: Record = {}, +): string { + const row = catalogRow(typeId); + if (row?.title !== "pipe") return row?.name ?? typeId; + const value = (key: string): unknown => { + const own = options?.[key]; + return own === undefined || own === null || own === "" ? defaults[key] : own; + }; + return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`; +} + +/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */ +export const fieldName = (key: string): string | undefined => + (PARTS as unknown as { "칸 이름"?: Record })["칸 이름"]?.[key]; + +/** + * 종류 바꿈 = 제자리(종류 키만 갈아 끼움)인가(323-6 · 표 규칙) — 정본이 같고, 구조물은 자리꼴도 같아야. + * 아니면(관 지점 ↔ 구조물 · E · F 점형 ↔ 구간형) 옛 것을 지우고 같은 기준 측점에 새로(구간 = 기본 길이). + * 관 지점 시설은 구간도 옵션(길이 · 전 · 후)이라 자리꼴로 안 가름. + */ +export function swapsInPlace(fromTypeId: string, toTypeId: string): boolean { + const from = catalogRow(fromTypeId); + const to = catalogRow(toTypeId); + if (!from || !to || from.owner !== to.owner) return false; + return from.owner === "pipe_points" || from.placement === to.placement; +} + +/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ +export function shapeNeeds(typeId: string): readonly string[] { + const shape = catalogRow(typeId)?.shape ?? "none"; + const box = (PARTS as unknown as Parts).parts.형상 as Record; + return Object.hasOwn(box, shape) ? (box[shape]["필수 칸"] ?? []) : []; +} + +/** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */ +export const pipeTitleKinds = (): readonly string[] => pipeKinds; diff --git a/common_util/common_util_structure_parts.json b/common_util/common_util_structure_parts.json new file mode 100644 index 000000000..9b3cbd800 --- /dev/null +++ b/common_util/common_util_structure_parts.json @@ -0,0 +1,514 @@ +{ + "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 칸 이름 = 어느 종류 정의에도 없는 읽는 칸의 화면 글(빈칸 경고 · 323-4). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", + "parts": { + "횡단도": { + "anchor": { + "이름": "기준 한 장", + "읽는 칸": [], + "자리꼴": ["point", "interval", "site"], + "정본": ["pipe_points", "structures"] + }, + "span3": { + "이름": "시작 · 기준 · 끝", + "읽는 칸": ["length_m", "before_m", "after_m"], + "자리꼴": ["interval"], + "정본": ["pipe_points", "structures"] + } + }, + "카드이름": { + "pipe": { + "이름": "관종 D관경", + "읽는 칸": ["pipe_kind", "pipe_diameter_mm"], + "자리꼴": ["point"], + "정본": ["pipe_points"] + } + }, + "형상": { + "pipe_set": { + "이름": "배관 세트(관 · 유입 · 유출 · 흙막이 · 도수로)", + "읽는 칸": [ + "pipe_kind", + "pipe_diameter_mm", + "revet_foundation", + "inlet_type", + "inlet_revet_form", + "inlet_revet_length_m", + "inlet_revet_before_m", + "inlet_revet_after_m", + "inlet_revet_height_m", + "inlet_basin_length_m", + "inlet_basin_before_m", + "inlet_basin_after_m", + "inlet_basin_height_m", + "outlet_type", + "outlet_revet_form", + "outlet_revet_length_m", + "outlet_revet_before_m", + "outlet_revet_after_m", + "outlet_revet_height_m" + ], + "자리꼴": ["point"], + "정본": ["pipe_points"], + "필수 칸": ["pipe_diameter_mm"] + }, + "box": { + "이름": "BOX 본체 · 날개벽", + "읽는 칸": [ + "body_width_m", + "body_height_m", + "wall_thickness_m", + "slab_thickness_m", + "wing_in", + "wing_in_height_m", + "wing_in_length_m", + "wing_in_angle_deg", + "wing_out", + "wing_out_height_m", + "wing_out_length_m", + "wing_out_angle_deg" + ], + "자리꼴": ["point"], + "정본": ["pipe_points"] + }, + "ford": { + "이름": "세월교(관 · 월류 · 날개벽)", + "읽는 칸": [ + "pipe_kind", + "pipe_diameter_mm", + "pipe_count", + "ford_width_m", + "ford_height_m", + "wing_in", + "wing_in_height_m", + "wing_in_length_m", + "wing_in_angle_deg", + "wing_out", + "wing_out_height_m", + "wing_out_length_m", + "wing_out_angle_deg" + ], + "자리꼴": ["point"], + "정본": ["pipe_points"] + }, + "ford_pavement": { + "이름": "물넘이 포장(파인 노면 · 도수로)", + "읽는 칸": ["ford_width_m", "ford_height_m", "ford_slope_deg", "ford_slope_pct"], + "자리꼴": ["point"], + "정본": ["pipe_points"], + "필수 칸": ["ford_height_m"] + }, + "hidden_revet": { + "이름": "독립 기슭막이(관 숨김 벽)", + "읽는 칸": [ + "form", + "height_m", + "length_m", + "before_m", + "after_m", + "tiers", + "side", + "foundation", + "face_role", + "face_slope_ratio" + ], + "자리꼴": ["interval"], + "정본": ["pipe_points"], + "필수 칸": ["height_m"] + }, + "wall": { + "이름": "벽(옹벽 · 흙막이 · 돌쌓기)", + "읽는 칸": [ + "form", + "height_m", + "side", + "foundation", + "tiers", + "lift_m", + "shift_m", + "face_role", + "face_slope_ratio" + ], + "자리꼴": ["interval"], + "정본": ["structures"], + "필수 칸": ["height_m"] + }, + "berm": { + "이름": "소단(사면 계단)", + "읽는 칸": ["width_m", "interval_m", "slope_deg", "depth_m"], + "자리꼴": ["interval"], + "정본": ["structures"] + }, + "underdrain": { + "이름": "맹암거", + "읽는 칸": ["culvert_height_m", "culvert_slope_pct"], + "자리꼴": ["point"], + "정본": ["structures"], + "필수 칸": ["culvert_height_m"] + }, + "pavement": { + "이름": "포장층", + "읽는 칸": ["pave_spec", "height_m", "widening_off"], + "자리꼴": ["interval"], + "정본": ["structures"] + }, + "spoil": { + "이름": "사토(성토 채움)", + "읽는 칸": ["capacity_m3"], + "자리꼴": ["site"], + "정본": ["structures"] + } + }, + "3D": { + "pipe": { + "이름": "관 · 유입 · 유출 벽", + "읽는 칸": [], + "자리꼴": ["point"], + "정본": ["pipe_points"] + }, + "box": { + "이름": "BOX 본체 · 날개벽", + "읽는 칸": [], + "자리꼴": ["point"], + "정본": ["pipe_points"] + }, + "ford": { + "이름": "세월교 몸체 · 관", + "읽는 칸": [], + "자리꼴": ["point"], + "정본": ["pipe_points"] + }, + "chute": { + "이름": "물넘이 도수로 띠", + "읽는 칸": [], + "자리꼴": ["point"], + "정본": ["pipe_points"] + }, + "wall": { + "이름": "벽", + "읽는 칸": [], + "자리꼴": ["interval"], + "정본": ["pipe_points", "structures"] + }, + "surface": { + "이름": "설계선만(몸체 없음)", + "읽는 칸": [], + "자리꼴": ["point", "interval", "site"], + "정본": ["pipe_points", "structures"] + } + } + }, + "칸 이름": { "ford_height_m": "월류 높이" }, + "처음값": { + "pipe": { + "목록": "both", + "횡단도": "anchor", + "카드이름": "pipe", + "형상": "pipe_set", + "3D": "pipe" + }, + "box_culvert": { + "목록": "both", + "횡단도": "anchor", + "카드이름": "name", + "형상": "box", + "3D": "box" + }, + "ford_pavement": { + "목록": "both", + "횡단도": "anchor", + "카드이름": "name", + "형상": "ford_pavement", + "3D": "chute" + }, + "ford_bridge": { + "목록": "both", + "횡단도": "anchor", + "카드이름": "name", + "형상": "ford", + "3D": "ford" + }, + "cross_drain_exposed": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "open_ditch": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "cross_underdrain": { + "목록": "both", + "횡단도": "anchor", + "카드이름": "name", + "형상": "underdrain", + "3D": "surface" + }, + "ditch_side": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "ditch_ridge": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "ditch_berm": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "chute": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "slope_drain": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "underdrain": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "retaining_wall": { + "목록": "both", + "횡단도": "span3", + "카드이름": "name", + "형상": "wall", + "형상칸": { + "form": "콘크리트", + "weep": true + }, + "3D": "wall" + }, + "masonry_wet": { + "목록": "hidden", + "횡단도": "span3", + "카드이름": "name", + "형상": "wall", + "형상칸": { + "form": "돌쌓기(찰)" + }, + "3D": "wall" + }, + "masonry_dry": { + "목록": "hidden", + "횡단도": "span3", + "카드이름": "name", + "형상": "wall", + "형상칸": { + "form": "돌쌓기(메)" + }, + "3D": "wall" + }, + "soil_guard": { + "목록": "both", + "횡단도": "span3", + "카드이름": "name", + "형상": "wall", + "형상칸": { + "form": "통나무·목재틀" + }, + "3D": "wall" + }, + "boulder_masonry": { + "목록": "hidden", + "횡단도": "span3", + "카드이름": "name", + "형상": "wall", + "형상칸": { + "form": "돌쌓기(메)" + }, + "3D": "wall" + }, + "berm": { + "목록": "both", + "횡단도": "span3", + "카드이름": "name", + "형상": "berm", + "3D": "surface" + }, + "erosion_check": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "bed_sill": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "check_dam_small": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "revetment": { + "목록": "hidden", + "횡단도": "span3", + "카드이름": "name", + "형상": "hidden_revet", + "3D": "wall", + "자리꼴": "interval" + }, + "evac_road": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "refuge": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "work_yard": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "turnaround": { + "목록": "hidden", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "mirror": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "curb": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "hazard_sign": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "guardrail": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "position_sign": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "water_intake": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "barrier_gate": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "completion_sign": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "spoil_bank": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "spoil", + "3D": "surface" + }, + "borrow_pit": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "wildlife_path": { + "목록": "b06", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "revegetation": { + "목록": "b06", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, + "pavement_concrete": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "pavement", + "3D": "surface" + }, + "gravel_surfacing": { + "목록": "b06", + "횡단도": "none", + "카드이름": "name", + "형상": "pavement", + "3D": "surface" + }, + "etc": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + } + } +} diff --git a/common_util/common_util_structure_pavement.py b/common_util/common_util_structure_pavement.py index 26d12b7d6..41fcffdbe 100644 --- a/common_util/common_util_structure_pavement.py +++ b/common_util/common_util_structure_pavement.py @@ -13,10 +13,12 @@ import math from pathlib import Path from typing import Any +from common_util.common_util_structure_station import EDGE_TOLERANCE_M + logger = logging.getLogger(__name__) -# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값. -_EDGE_TOLERANCE_M = 0.02 +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 같은 자리 거리 한 곳(323-7) +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M def _num(value: Any) -> float | None: @@ -105,10 +107,10 @@ def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str, def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명).""" from B05_Profile.B05_Profile_Structures_Repository import load_structures - from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + from common_util.common_util_structure_catalog import type_ids_with try: - type_ids = {key for key, value in structure_type_map().items() if value.group == "G"} + type_ids = type_ids_with(shape="pavement") # 표 형상(322-1 · 옛 G 군) structures = [item.model_dump() for item in load_structures(str(project_root))[1]] except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감 logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)") diff --git a/common_util/common_util_structure_shape_keys.ts b/common_util/common_util_structure_shape_keys.ts new file mode 100644 index 000000000..7b3995390 --- /dev/null +++ b/common_util/common_util_structure_shape_keys.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * common_util/common_util_structure_shape_keys.ts + * 측점 그림 자료 열쇠(323-4) — 형상 얹기 한 벌(`common_util_structure_attach.ts`)이 걷고 얹는 것 = 카드 · + * 상세 창 · B07 · 3D 가 읽는 것 = 3D 지문(`B05_Profile_UI_Corridor_Structures_Hash.ts`). 가져오는 것 없음 + * (3D 도움 파일 로더도 그대로 읽게). 파이썬 짝 = `B06_Section_Engine_Attach.SHAPE_KEYS`. + * ========================================================================== */ + +export const SHAPE_KEYS = [ + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", +] as const; diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 63d2b8622..ef0370865 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -4,7 +4,8 @@ 만들기가 한 측점으로 합침(구조물 측점이 위 · `B05_Profile_Engine_Sections_Core`). 234-1 의 「일반 측점 1 m 안 붙기」는 뺌(그냥 정수 · 사용자 167). TS 짝 = `common_util/common_util_structure_station.ts` -(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김). +(화면 입력 · 끌기 · 방향키 · 옛 자료 정수 옮김 · +측점 계획 `plan_stations` ↔ `planStations` · 322-3). 거울 시험 = `resources/tester/test_234_1_structure_station.py`. 일반 측점 = 격자(간격) · 시종점 — 끝(노선 길이)을 넘지 않게 자르는 데만 씀. """ @@ -67,3 +68,112 @@ def snap_candidates( x, y, _ = interpolate_vertex(list(vertices), at) snapped.append(replace(candidate, chainage_m=at, x=x, y=y)) return snapped + + +# ── 측점 계획 한 벌(322-3 · TS `planStations` 짝 · 거울 = test_322_3_plan_mirror.py) ── + +# ── 같은 자리 거리 한 곳(323-7 · 파이썬은 여기서 가져다 씀 · TS 짝은 이름이 같은 상수) ── +#: 같은 측점으로 볼 거리(m) — 측점 계획 · 반올림 차이만 · TS `SAME_STATION_M` 짝 +SAME_STATION_M = 0.005 +#: 구간 끝 · 관 세트가 걸치는 측점을 같은 자리로 볼 폭(m) — 정본이 누가거리를 0.01 m 로 끊어 씀 · +#: TS `EDGE_TOLERANCE_M`(벽 · 포장 · 맹암거) · `CHAINAGE_TOLERANCE_M`(관 세트) 짝 +EDGE_TOLERANCE_M = 0.02 +#: 관 · 구조물 자리와 측점 · 고름을 같은 자리로 볼 폭(m) — 정수 m 기준 측점 · 옛 소수 자료 · TS +#: `design_selection.PIPE_MATCH_M` 짝 +PIPE_MATCH_M = 0.51 + + +def _empty(value: object) -> bool: + return value is None or value == "" + + +def blank_keys(type_id: str, options: dict | None) -> list[str]: + """형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝).""" + from common_util.common_util_structure_catalog import shape_needs + + return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))] + + +def _station_marks( + start: object, anchor: object, end: object, span3: bool +) -> list[tuple[str, float]]: + """TS `marksOf` 짝 — 수인 자리만 (역할, 누가거리).""" + marks = [("start", start), ("anchor", anchor), ("end", end)] if span3 else [("anchor", anchor)] + return [ + (role, float(at)) + for role, at in marks + if isinstance(at, (int, float)) and not isinstance(at, bool) and math.isfinite(at) + ] + + +def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list[dict[str, object]]: + """관 · 구조물(현황) + 구조물 표 → 측점 계획 `[{chainage_m, role, title, owners, blank}]`. + + 오름차순 · 같은 m(0.005) 는 하나 — 이름 = 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름) + · 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상 + 부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌. + """ + from common_util.common_util_structure_catalog import card_title, catalog_row + + blank = blank_keys + + marks: list[dict[str, object]] = [] + for pipe in pipes: + facility = getattr(pipe, "facility", None) or "pipe" + row = catalog_row(facility) + if row is None: + continue + options = getattr(pipe, "options", None) + chainage = float(getattr(pipe, "chainage_m")) + station = row["station"] + for role, at in ( + [] + if station == "none" + else _station_marks( + getattr(pipe, "start_m", None), + chainage, + getattr(pipe, "end_m", None), + station == "span3", + ) + ): + marks.append( + { + "chainage_m": at, + "role": role, + "title": card_title(facility, options), + "owner": getattr(pipe, "id", None) or f"pipe-{chainage:.2f}", + "blank": blank(facility, options), + "pipe": True, + } + ) + for item in structures: + row = catalog_row(item.type_id) + if row is None or row["owner"] == "pipe_points" or row["station"] == "none": + continue + anchor = item.chainage_m if item.chainage_m is not None else item.start_m + found = _station_marks(item.start_m, anchor, item.end_m, row["station"] == "span3") + base = next((at for role, at in found if role == "anchor"), 0.0) + for role, at in found: + marks.append( + { + "chainage_m": at, + "role": role, + "title": card_title(item.type_id, item.options), + "owner": item.structure_id or f"structure-{base:.2f}", + "blank": blank(item.type_id, item.options), + "pipe": False, + } + ) + marks.sort(key=lambda mark: mark["chainage_m"]) + out: list[dict[str, object]] = [] + for mark in marks: + last = out[-1] if out else None + if last is not None and abs(mark["chainage_m"] - last["chainage_m"]) < SAME_STATION_M: + if mark["pipe"] and not last["pipe"]: + last["title"], last["pipe"] = mark["title"], True + if mark["owner"] not in last["owners"]: + last["owners"].append(mark["owner"]) + last["blank"] += [key for key in mark["blank"] if key not in last["blank"]] + continue + out.append({**mark, "owners": [mark["owner"]], "blank": list(mark["blank"])}) + return [{k: v for k, v in station.items() if k not in ("pipe", "owner")} for station in out] diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index ac0cb7d52..9d14c770d 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -9,6 +9,13 @@ * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 끝(노선 길이)을 넘지 않게 자르는 데만 · * 상세를 읽을 때 한 번 둔다. * + * 심는 측점 = 구조물 표(catalog) 횡단도 부품(322-1) — anchor = 기준 하나 · span3 = 시작 · 기준 · 끝 · none = 없음. + * + * 측점 계획 한 벌(322-3 · `planStations`) — 관 · 구조물(현황) + 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id · + * 빈칸 경고 · 같은 m 는 하나(관이 먼저 · 이름 = 관 → 앞선 것). 파이썬 짝 = `common_util_structure_station.py` + * `plan_stations`(서버 `resolve_extra_stations`) · 거울 시험 = `resources/tester/test_322_3_plan_mirror.py`. + * 카드 목록 · B05 종단 · B06 종단 띠 · 3D 막대 · 미리 뜬 횡단 이름이 이 목록 하나를 씀. + * * 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations` * (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버 * `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 = @@ -16,8 +23,15 @@ * 이 목록 하나를 씀. * ========================================================================== */ +import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog"; + const EPS = 1e-6; +/* 같은 자리 거리(323-7 · 뜻마다 하나 · 파이썬은 `common_util_structure_station.py` 한 곳에서 가져다 씀): + * SAME_STATION_M 0.005 = 같은 측점(반올림 차이) — 여기 + * EDGE_TOLERANCE_M · CHAINAGE_TOLERANCE_M 0.02 = 구간 끝 · 관 세트가 걸치는 측점(정본 0.01 m 끊음) — 벽 · + * 포장 · 맹암거 · 관 세트 모듈(거울 시험이 tsc ESM 으로 따로 엮어 각자 둠 · 값은 파이썬 `EDGE_TOLERANCE_M`) + * PIPE_MATCH_M 0.51 = 관 · 구조물 자리와 측점 · 고름(정수 m · 옛 소수) — `design_selection` */ /** 같은 측점으로 볼 거리(m) — 서버 측점 만들기의 격자 붙임 폭(`Engine_Sections_Core`)과 같음. */ export const SAME_STATION_M = 0.005; @@ -111,39 +125,120 @@ function marksOf( ); } -/** 관 시설 하나가 심는 측점 — 독립 기슭막이 = 시작 · 기준 · 끝 · 그 밖(물넘이포장 포함 · 286-1) = 기준. */ +/** 관 시설 하나가 심는 측점 — 표 횡단도(독립 기슭막이 = span3 · 물넘이포장 포함 그 밖 = anchor · 286-1). */ export function pipeStationMarks(pipe: PlantingPipe): StationMark[] { - const facility = pipe.facility ?? "pipe"; - return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, facility === "revetment"); + const station = catalogRow(pipe.facility ?? "pipe")?.station ?? "anchor"; + if (station === "none") return []; + return marksOf(pipe.start_m, pipe.chainage_m, pipe.end_m, station === "span3"); } -/** 구조물 하나가 심는 측점 — A군 = 기준 · C · D군 구간형 = 시작 · 기준 · 끝 · 다른 정본 소관 · 그 밖 = 없음. */ +/** 구조물 하나가 심는 측점 — 표 횡단도(anchor = 기준 · span3 = 시작 · 기준 · 끝) · 관 지점 정본 소관 · 모르는 종류 = 없음. */ export function structureStationMarks( structure: PlantingStructure, type: PlantingType | undefined, ): StationMark[] { - if (!type || type.managed_by) return []; - const interval = type.placement === "interval"; - if (type.group !== "A" && !(interval && (type.group === "C" || type.group === "D"))) return []; + const row = type && catalogRow(type.type_id); + if (!row || row.owner === "pipe_points" || row.station === "none") return []; const anchor = structure.chainage_m ?? structure.start_m; - return marksOf(structure.start_m, anchor, structure.end_m, interval); + return marksOf(structure.start_m, anchor, structure.end_m, row.station === "span3"); } -/** 측점을 심는 자리 전부(오름차순 · 겹침 그대로) — 서버 `resolve_extra_stations` 의 누가거리. */ -export function plantedChainages( - pipes: ReadonlyArray, - structures: ReadonlyArray, - types: ReadonlyArray, -): number[] { +/* ── 측점 계획 한 벌(322-3 · 파이썬 `plan_stations` 짝) ── */ + +export interface PlanPipe extends PlantingPipe { + /** 관 세션 id — 없으면 자리로 지음(`pipe-{누가거리 2자리}`). */ + id?: string | null; + options?: Record | null; +} + +export interface PlanStructure extends PlantingStructure { + structure_id?: string | null; + options?: Record | null; +} + +export interface PlanType extends PlantingType { + options?: ReadonlyArray<{ key: string; default?: unknown }>; +} + +/** 측점 한 곳 — 같은 m 에 놓인 관 · 구조물을 함께 덮음. */ +export interface PlannedStation { + chainage_m: number; + role: StationMark["role"]; + /** 카드 · 측점 이름 — 관이 있으면 첫 관 · 아니면 앞선 구조물(표 카드 이름). */ + title: string; + /** 덮는 구조물 id(관 = 세션 id · 구조물 = structure_id). */ + owners: string[]; + /** 빈칸 경고 — 형상 부품 필수 칸 중 값이 빈 옵션 키(비면 그 그림이 안 섬). */ + blank: string[]; +} + +const empty = (value: unknown): boolean => value === undefined || value === null || value === ""; + +/** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */ +export const blankKeys = ( + typeId: string, + options: Record | null | undefined, +): string[] => shapeNeeds(typeId).filter((key) => empty(options?.[key])); + +/** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션 + * 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */ +export function planStations( + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, + registry?: Readonly>>, +): PlannedStation[] { const byId = new Map(types.map((type) => [type.type_id, type])); - return [ - ...pipes.flatMap(pipeStationMarks), - ...structures.flatMap((structure) => - structureStationMarks(structure, byId.get(structure.type_id)), - ), - ] - .map((mark) => mark.chainage_m) - .sort((a, b) => a - b); + const defaults = (typeId: string): Record => + registry?.[typeId] ?? + Object.fromEntries((byId.get(typeId)?.options ?? []).map((o) => [o.key, o.default])); + type Mark = StationMark & { title: string; owner: string; blank: string[]; pipe: boolean }; + const marks: Mark[] = [ + ...pipes.flatMap((pipe) => { + const facility = pipe.facility ?? "pipe"; + if (!catalogRow(facility)) return []; + const title = cardTitle(facility, pipe.options, defaults(facility)); + const owner = pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`; + const blank = blankKeys(facility, pipe.options); + return pipeStationMarks(pipe).map((m) => ({ ...m, title, owner, blank, pipe: true })); + }), + ...structures.flatMap((structure) => { + const marks = structureStationMarks(structure, byId.get(structure.type_id)); + const anchor = marks.find((m) => m.role === "anchor")?.chainage_m ?? 0; + const title = cardTitle(structure.type_id, structure.options, defaults(structure.type_id)); + const owner = structure.structure_id ?? `structure-${anchor.toFixed(2)}`; + const blank = blankKeys(structure.type_id, structure.options); + return marks.map((m) => ({ ...m, title, owner, blank, pipe: false })); + }), + ].sort((a, b) => a.chainage_m - b.chainage_m); + const out: Array = []; + for (const mark of marks) { + const last = out[out.length - 1]; + if (last && Math.abs(mark.chainage_m - last.chainage_m) < SAME_STATION_M) { + if (mark.pipe && !last.pipe) Object.assign(last, { title: mark.title, pipe: true }); + if (!last.owners.includes(mark.owner)) last.owners.push(mark.owner); + for (const key of mark.blank) if (!last.blank.includes(key)) last.blank.push(key); + continue; + } + out.push({ + chainage_m: mark.chainage_m, + role: mark.role, + title: mark.title, + owners: [mark.owner], + blank: [...mark.blank], + pipe: mark.pipe, + }); + } + return out.map(({ pipe: _pipe, ...station }) => station); +} + +/** 측점을 심는 자리 전부(오름차순 · 같은 m 하나) — 측점 계획의 누가거리(서버 `resolve_extra_stations`). */ +export function plantedChainages( + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): number[] { + return planStations(pipes, structures, types).map((station) => station.chainage_m); } /* ── 측점 한 벌(272 · 서버 `_merge_irregular_into_longitudinal` 짝) ── */ diff --git a/common_util/common_util_structure_underdrain.ts b/common_util/common_util_structure_underdrain.ts index ca2a91d44..e992610e6 100644 --- a/common_util/common_util_structure_underdrain.ts +++ b/common_util/common_util_structure_underdrain.ts @@ -5,9 +5,11 @@ * 기준 측점(0.02 m 안) 횡단 dict 에 `underdrain` = 높이 · 경사 — 횡단도가 돌 무늬 형상을 그리고, * 설계 계산은 그 측점을 측구 없이 지대 낮은 쪽 한 경사로 기울임(`tilted`). * 파이썬 짝 = `B06_Section/B06_Section_Engine_Tilt.py`(`load_underdrains` · `attach_underdrains`). + * 맹암거 종류 = 구조물 표 형상 underdrain(322-1). * ========================================================================== */ -export const UNDERDRAIN_TYPE_ID = "cross_underdrain"; +import { catalogRow } from "./common_util_structure_catalog"; + const EDGE_TOLERANCE_M = 0.02; /** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */ @@ -35,7 +37,7 @@ const positive = (value: unknown): number | null => { export function underdrainSpecsFrom(structures: readonly UnderdrainInput[]): UnderdrainSpec[] { return structures.flatMap((structure) => { const at = structure.chainage_m ?? structure.start_m; - if (structure.type_id !== UNDERDRAIN_TYPE_ID || typeof at !== "number") return []; + if (catalogRow(structure.type_id)?.shape !== "underdrain" || typeof at !== "number") return []; const options = structure.options ?? {}; return [ { diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 8e1d65f49..5edcbbd67 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -8,8 +8,12 @@ * 산식이 같아야 하므로 두 파일 머리에 짝임을 적고 거울 테스트를 둔다. * * 얹는 것은 제원뿐이다 — 도형·면적은 `B06_Section_UI_Cross_Revetment` 가 그린다. + * 벽 기본 형태(폼에서 형태를 비웠을 때) = 구조물 표 형상칸.form(322-1 · 옛 `FORM_BY_TYPE`). * ========================================================================== */ +// `.js` = 짝 거울 시험이 tsc → Node ESM 으로 이 파일만 엮음(확장자 있어야 찾음 · vite 는 .ts 로 풂) +import { baseType, catalogRow } from "./common_util_structure_catalog.js"; + /** 구조물 정본 한 건(필요한 칸만). `structures.json` 과 같은 이름을 쓴다. */ export interface WallStructureInput { structure_id?: string | null; @@ -54,7 +58,7 @@ export interface WallTier { const EDGE_TOLERANCE_M = 0.02; /** - * 구조물 종류 → 횡단 기하가 아는 형태 이름 — 파이썬 `_FORM_BY_TYPE` 와 같은 표. + * 구조물 종류 → 횡단 기하가 아는 형태 이름 = 표 형상칸.form(파이썬 `_wall_form` 짝). * * ⚠⚠ **「형상 동일」이 「수량 동일」이 아니다**(2026-09-09 사용자 확정 4차 원문: * 「흙막이는 횡단도에서 표현방식들과 옵션들은 동일하게 반영 · **형상은 동일** · @@ -64,12 +68,9 @@ const EDGE_TOLERANCE_M = 0.02; * ⚠ 반대 방향의 사례가 골막이다 — 그림도 수량도 다르고, 두께식마저 다른 식이었다. * **구조물 계열이 다르면 상수를 나눠 쓸 것.** */ -export const FORM_BY_TYPE: Record = { - masonry_wet: "돌쌓기(찰)", - masonry_dry: "돌쌓기(메)", - boulder_masonry: "돌쌓기(메)", - retaining_wall: "콘크리트", - soil_guard: "통나무·목재틀", +const formOf = (typeId: string): string | null => { + const form = catalogRow(typeId)?.shapeOpts.form; + return typeof form === "string" ? form : null; }; const num = (value: unknown): number | null => @@ -88,7 +89,11 @@ export function tierExtras(typeId: string, options: Record): Wa ), ].sort((a, b) => a - b); return ns.map((n) => ({ - form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null, + // 옹벽(본보기 포함 · 323-7) 추가 단 = 콘크리트 + form: + baseType(typeId) === "retaining_wall" + ? "콘크리트" + : (options[`tier_${n}_form`] as string) || null, height_m: num(options[`tier_${n}_height`]), })); } @@ -117,7 +122,7 @@ export function wallSpecsFrom( 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, + form: (options.form as string) || formOf(structure.type_id), height_m: num(options.height_m), side: (options.side as string) ?? null, // 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다. diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index b7f6ea25d..1f52bbcf1 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -245,3 +245,22 @@ 214. (원문 · 10-13) 구조물 변경에서 아직 겹쳐지는 구조물들이 존재함. 내가 봤을때는 캐시데이터 충돌도 있는것같음. 리프레시하면 최종선택한 구조물로 형상이 변경된 그림이 나옴. 확인만 해보자. (이어서) 너가 직접해 215. (원문 · 10-13) B06의 횡단도에서 횡단도 하이라이트 기능의 선택영역중에 측구/일반/l형/복합경사/상세 버튼이 존재하는 영역이 누락된것같아. 확인만 해봐. 216. (원문 · 10-13) 1. 구조물 종류를 바꿀때 옛 측점별 조정값이 저장기록에 섞이지 않게 해줘. 2. 횡단도의 중심고 표현이 횡단도 하단 띠 좌측에 표현되고 있는데 이걸 띠에서는 삭제하고 그래프 영역의 Y축 라벨에 같이 반영해주는데 박스와 테마에 맞춰 잘보이는 색상의 폰트 색상과 사각라운드를 활용하여 표기해줘. 이때 폰트를 크게할필요는 없고 다른 Y라벨과 동일한 폰트사이즈로 하고 대신 박스가 Y 축선에 침범하지 않게 해줘. 띠에 있던 글자는 삭제하고 암경계조절을 좌측맞춤으로 해줘. 3. 횡단도의 하단띠 하단에 여유공간이 있음. 띠의 높이는 조절하지말고 여유를 없애줘. 아마 컨테이너 외곽이 올라가능 방향이 될거야. 4. 횡단도 하이라이트 영역 조정은 너의 제안대로 해줘. 4. 이것도 너가 직접 작업해줘. +217. (원문 · 10-14) 추가수정 1. Y축 숫자에 중심고 표현에서 근처에 있는 정규 Y 값들을 중첩되면 숨김처리하는데 숨김처리하지말고 중심고 라벨을 위해 위에배치하면 괜찮을것같은데? 만약 정규 Y값이 비치면 라벨의 배경색을 안비치게 변경하는것도 좋아. 2. 횡단배수 구조물변경을 할경우 해당 측점에서 맹암거 변경안됨. 만약 횡단도에 횡단배수 구조물 중 형상이 없는 횡단도의 경우 없는 걸로 변경하고 다시 다른것도 변경해야할것같은데? +218. (원문 · 10-14) 직접 브라우져확인하고 이번 기회에 구조물 점검을해보자. 지금까지 결과를보면 횡단배수군과 그 외 구조물들로 나눌수 있고 그룹을 넘어가는 경우에는 좌측패널이 리셋됨. 그리고 그룹내 다른 구조물을 선택하면 자유롭게 변경되며 해당선택지의 구조물그림으로 변경함. 이때 구조물이 그림이 없는 경우 횡단도추가 하지않고 있더라도 삭제한다. 일단 정밀 점검해서 테이블로 현재 드롭다운 메뉴의 구조물 리스트를 만들어 현황공유. 내용이 많으면 아티팩트로 +219. (원문 · 10-14) 내가 판단하기에는 현재의 드롭다운 메뉴가 결국 최신의 구조물 현황되어야해. 하지만 지금 b05,b06의 횡단도, 하이라이트, 횡단도 구조물 형상, 구조물 하이라이트, 종단 측점, 종단 알약, 3d측점 형상 들이 조건에따라 반영들이 바뀌고 있고 향후 유지보수나 구조물을 넣고 빼는데 계속 어려움이 발생되고 있어 정밀 분석해서 수정이 용이하고 오류가 없게 코드들의 구조를 잡아야 할것같은데 제안을 해줘. 대규모 리팩토링을 해서라도 잡고 넘어가는게 맞겠어. 일단 코드수정은 아니니 다이러그램을 이용해서 동작 플로우를 만들고 어떻게 정보들을 연결할지 현재와 비교 분석보고서를 만들고 코드반영할지 고민해보자 . 토큰 소모가 많아도 되니 꼼꼼히 분석해줘 +220. (원문 · 10-14) 확인했고 별도의 구조물 관리 테이블을 운용하여 오류를 줄이자는 말이네. 여기에 추가로 검토해줘야 하는네용이 테이블관리하여 내가 제어했으면 좋겠어. 코드야 만들어두고 활성화할지 비활성화할지 어떤 메뉴들이 들어올지 내가 결정할 수있었으면 해. 그래서 m02에 보면 구조물의 옵션 텍스트를 변경할수있게 제어하는 메뉴가 있는데 이걸 통합하여 운영하는거야. 다시 정밀 점검 해봐. 완료되면 신규 계획서를 작성할거야 +221. (원문 · 10-14) 이번 수정은 일단 랩탑 서브 1에 반영하고나서 내가 직접 확인후 병합여부를 결정할거야. 그러니 조심하고 너도 계획서의 전체 내용을 반영하고 브라우져 검증진행해줘. +222. (원문 · 10-14) 너가 브라우져로 접근해서 사용자처럼 전체 변경에 따른 미반영내용이 있는지 오류가 있는지 검증해줘. 오래걸려도 되니 하나씩 하나씩 지정해줘. +223. (원문 · 10-14) 1. 고칠 흠은 진행 +2. M02의 종류더하기에 맹암거와 옛종류들은 일단 두고 비활성화 상태면 괜찮음. 나중에 살릴수도 있음. 활성비활성은 내가 제어하겠음. +3. 횡단도에 횡단배수의 맹암거 옵션 기본값 3m, 3% 지정 +4. 전역깃싱크 할것. +5. 전역 깃 싱크 후 추가 검토 업무 + - 실무문서 중 2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls 파일의 일위대가 탭은 실무자들이 주로 사용하는 일위대가의 전체를 모와둔 자료야. 아마 사용자들이 구조물조합을 구성하기위해 반복적으로 검색과 옵션을 선택해야하는 어려움이 있음. M02 좌측 패널에서 사용자가 파일을 입력할수 있게 버튼을 생성(일위대가 실무자료)해주고 입력받은 엑셀의 일위대가탭을 기준으로 분석을 하는 로직을 추가하였으면함. + - 사용자는 구조물조합의 일위대가의 로직추가할 경우 해당 일위대가 실무자료에 있는 호표항목을 불러올수 있는 기능 부여 + - 이때 중요한 점은 있는 엑셀의 일위대가의 단가산출 이름들과 우리 프로그램의 단가산출 로직의 이름이 상이함. 매칭이 되는 경우에는 엑셀의 단가산출 이름을 일위대가의 별칭으로 입력해주면됨. + - 이때 옵션값은 규격열에 명기되어 있음. + - 결과적으로 내가 필요한 프로그램 로직은 다른 사람들이 가지고 있는 실무문서의 내용을 가져와 우리의 일위대가로 만들고 싶음. + - 단위가 상이한 경우에는 우리의 단가산출 로직에 단위변환에 따른 결과 로직을 부여하여 맞출것 + - 신규계획서를 작성이 필요하며 작성전 상세분석을 진행하여 코드의 영향을 분석을 진행. +6. 4번 전역 깃 싱크를 필히 한후 추가 검토 및 반영 업무 진행할 것. diff --git a/docs/raw/조사/2026-10-14_321-322_구조물관리표.md b/docs/raw/조사/2026-10-14_321-322_구조물관리표.md new file mode 100644 index 000000000..b8021e308 --- /dev/null +++ b/docs/raw/조사/2026-10-14_321-322_구조물관리표.md @@ -0,0 +1,71 @@ +# 321 · 322 구조물 관리 표 — 조사 (요구 219 · 220) + +기준 = sub_laptop_2 2641c15e2(요구 217 반영) · 코드 수정 없음 · 보고서 = 아티팩트 https://claude.ai/artifact/RwfpjSehc9ZdAjXxcP8DQa (판 2). +경로 줄임 — `B05/X` = `B05_Profile/B05_Profile_X` · `B06/X` = `B06_Section/B06_Section_X` · `SO/X` = `M02_MasterTemplete/StructOptions/M02_SO_X` · `dz/X` = `A00_Common/design/design_X` · `cu/X` = `common_util/common_util_X`. + +## 결론 한 줄 + +갈래마다 「가리는 쪽」(종류 키 · 군 글자 · 자리꼴 · 정본 · 시설 이름)이 흩어져 있고 「그리는 쪽」은 이미 그림 자료만 봄 → 가리는 쪽을 M02 정의 표 + 코드 부품 상자 한 줄 읽기로 바꾸면 일곱 갈래가 표 하나를 따름. 종류 이름 직접 적은 곳 279곳 · 78파일(B05 · B06 · 공용) 거의 다 가리는 쪽. + +## 1. 정의 파일 · 퍼짐 + +- 정본 `resources/master_template/구조물정의.json` 판 20 · 42종 · 틀 `docs/raw/M02_구조물옵션정의/정의_틀.md`. +- 서버 읽기 `B05/Structures_Schema.py:108-136`(파일 시각 캐시 · 바로) · 예외 `B06/Engine_Culvert.py:150-160` `_registry_defaults` lru = 서버 다시 켤 때까지 옛 값. +- 화면 `B05/Api_Structures.ts:196-224` typesCache = 탭 동안 한 번 · M02 화면 글 「바로 적용」(`SO/Text.ts:109`)과 어긋남. +- 쓰기 `SO/Definition.py:200-214`(판 +1 · 이력 `구조물정의_이력/판_N.json`) · 검사 `:174-197`(정의 모양만 · 프로젝트 자료 안 봄). +- 판 = `structures.json` 에만 적음(`B05/Structures_Repository.py:132-133`) · 읽는 곳 없음 · 관 지점 · 횡단 파일엔 없음 · 프로젝트별 정의 사본 없음. + +## 2. M02 화면 지금 + +- `SO/Def.ts` — 고칠 수 있는 것 = 구조물군 이름 · 순서 · 종류 이름 · 순서 · 「안 씀」 · 이름 나무(`SO/Def_Spec_Model.ts:141-157` → 종류.이름 · 옵션 label · 그룹 이름) · 옛 편집기 `OLD_EDITOR=false`(:36) · 종류 더하기 없음 · 특수 동작 = 태그 보기만. +- 이력 — PLAN 48(10-5 · M02 에서 바꾸면 코드 고침 없이 따라감 · 사용자 답 = 시스템 관리자만 · 바꿈 드묾 · 바로 적용 · 지난 값 이력) → PLAN 52(입력 구조 = 코드 명세 `dz/structure_pipe.ts:111` STRUCTURE_SPECS · M02 = 이름만) → 52-12 옛 편집기 숨김. +- 옛 M02 화면(`SO/Options.ts` 보이기 · `SO/Groups.ts`)은 안 붙음 · PUT structure-options · -groups 는 붙어 있으나 부르는 TS 없음. + +## 3. 칸마다 따르나 + +- 따름: 군 안 종류 순서 · 구조물군 이름 · 종류.이름(명세 폼 · 선택지 글) · placement(서버 저장 검사) · phase(일반 폼) · default(관 기본값 캐시 빼고). +- 일부: enabled — 서버 안 읽음(`B05/Structures_Repository.py:171-193`) · B05 `setTypes(전부, 켜진 것)`(`B05/UI_Page_Structures.ts:353`) · B06 `setTypes(전부)`(`B06/UI_Page_Structures_Panel.ts:380`) · B06 저장 전 덧입힘 켜진 것만(`B06/Section_Store.ts:88-114` · 쇄석 사라짐) / name — 관 지점 고정 이름 `B05/UI_Page_Helpers.ts:230-236`(배관 = 「배수관」) · 도움말 `구조물_<이름>.md`(`B05/UI_Structures_Panel.ts:246`) · 사전조합 분류 = 군 · 종류 이름(`M02_MasterTemplete_UI_Side_Precombo.ts:52-60`) · 관 이름 config 관종(이중벽관 · 삼중벽관 · 파형강관) ≠ 정의 관종(흄관 · VR관 · 파형강관) / style.color — 알약 · 유역도 띠만 / 표시 · 규칙 · 배치 — 일반 폼만(명세 폼 안 따름) · 서버는 규칙 안 지킴. +- 안 읽음: 구조물군 순서(드롭다운 군 = types 처음 나온 차례 `B05/UI_Structures_Panel_Types.ts:82`) · 동작(`B05/UI_Structures_Define.ts:208` 싣기만) · style.abbr · drawing_views(값도 실제와 다름 — 개거 · 가드레일 「횡단」) · 판. +- 코드가 덮음: HIDDEN_PICK_GROUPS {B, D}(`Panel_Types.ts:41`) · HIDDEN_PICK_TYPES 돌쌓기 셋(:44-48) · ROAD_DETAILS 대피소 셋(`B05/UI_Structures_Define.ts:221-229`) → 정의에선 켜진 10종 숨김 · 군 글자 규칙(측점 A · C/D 구간 · 벽 C 구간 소단 빼고 · 포장 G · 숨김 B/D) · 정본 목록(`cu/drainage_pipes.py:62-75` · `B04_PreProcess/B04_PreProcess_Api_Fetch.ts:475`). + +## 4. 종류 이름 바꿈 = 「기타」 유령 (코드 확인 · 재현 전) + +- B05 열 때(탭 · 노선마다 한 번) 비정규 측점 이름을 이관 요청 `dz/view_profile.ts:460-482` → 지금 이름 · 관 이름 꼴에 없으면 etc · 이름 = 옛 이름(`B05/Structures_Migration.py:57-79` · `:120-133` · `:186-188`) → 자리 열쇠 「종류@측점」이 달라 새로 저장(`B05/Structures_Router.py:197-239`). +- 걸리는 것 = A 구조물 셋 · C 구간형(측점 셋 = 유령 셋) · 관 지점 BOX · 물넘이 · 세월교 · 기슭막이 · 배관은 「관 D」 꼴이라 안전. +- 서버 측점 이름 다시 붙이기는 지금 이름만(`B05/Engine_Sections.py:238-245`) → 카드에 옛 이름 남음. + +## 5. 바꿈 안전 + +| 바꿈 | 지금 | 막을 것 | +| --- | --- | --- | +| 종류 이름 | 유령 · 옛 이름 · 도움말 · 사전조합 분류 | 이름 되짚기 걷음 · 이름표를 종류 키로 | +| 칸 · 선택지 이름(이름 표) | 안전 | – | +| 선택지 값 | 저장 400(`Repository.py:210-211`) · 집계 · 형상 글자 비교 조용히 빠짐 | M02 에서 못 바꿈 | +| 안 씀 | 대체로 안전(서버 무시) · B04 자동 추천은 무시 | – | +| 자리꼴 | 그 종류 가진 프로젝트 저장 400(`Repository.py:188-192`) · 묶음 저장은 구조물만 빠짐(`dz/save.ts:92-96`) | 만든 뒤 고정 | +| 종류 지움 | 저장 400(`Repository.py:180-182`) · etc · revetment · refuge 지우면 이관 오류(`Migration.py:129`) | 지우기 없음 | +| 군 글자 | 측점 · 벽 · 포장 · 숨김이 조용히 바뀜 | 만든 뒤 고정 | +| managed_by | 새 관 지점 종류 = 서버가 배관으로(`cu/drainage_pipes.py:357`) | 만든 뒤 고정 · 정본 목록을 표에서 | +| 종류 더하기 | 화면 없음 · 형상 · 3D 없음 | 본보기 복사 + 부품 | + +- 쓰는 곳 세기 — `storage/{회사}/{사용자}/{프로젝트}/B05_Profile/route/structures.json` · `B04_PreProcess/drainage/edits/pipe_points.json`(initial_snapshot 빼고) · 초안은 서버가 못 봄. + +## 6. 부품 (그리는 쪽 · 지금 함수) + +- 횡단도 — 기준 한 장 `cu/structure_station.ts:115 · 121`(A · 관 지점) · 세 장 `:117`(C · 기슭막이 이름 직접) · 파이썬 `B05/Engine_Sections.py:257-312` · 카드 이름 관 공식 `:209`(빈칸 D800) ↔ `B06/Section_Store.ts:177-181` · `B06/Section_Drafts.ts:129-134`(빈칸 D1000). +- 형상 — 서버 얹기 차례 `B06/Router.py:283-289` · 화면 짝 `B06/Section_Store.ts:70-184` · 그리기 `B06/UI_Cross_View.ts:421-619`(그림 자료 열쇠로) · 관 세트 `cu/culvert_sets.ts:239`(py `B06/Engine_Culvert.py:284`) · 숨은 관 흙막이 :288 · 박스 :522 · 세월교 :443 · 물넘이 :488 · 도수로 :204 · 벽 `cu/structure_walls.ts:100 · 141`(기본 형태 :67 · 소단 빼기 `Section_Store.ts:103`) · 맹암거 `cu/structure_underdrain.ts:35 · 52` · 포장층 `cu/structure_pavement.ts:58 · 87`(G 군) · 사토 `B06/Engine_SpoilFill.py:101 · 169`(저장 때만). +- 빈칸 — 관경 · 벽 높이 · 맹암거 높이 = 안 그림 · 경고 없음 / 물넘이 높이 · 기슭막이 높이 = 경고 칩 / 박스 · 세월교 = 기본값 칩. +- 강조 — 관 부분 키 · 벽 own · 본체 · data-structure-id(맹암거 · 물넘이) · 포장층(G 글자) · 소단 조각 · 카드 범위 띠(관 지점은 늘 점이라 물넘이 · 기슭막이 없음). +- 알약 `B05/UI_Structures_Marks.ts:177 · 190`(띠 = 놓인 것 자리꼴) · 3D 몸체 `B05/UI_Corridor_Structures.ts:273`(관 · 박스 · 세월교 · 도수로 · 벽 · 벽은 덮는 측점마다 겹침 :285 · 358 의심) · 3D 에 없음 = 포장층 · 맹암거 몸체 · 사토. +- 종류 전용 — 월류 높이 `dz/structure_ford_calc.ts:23 · 35` · BOX 규격 `dz/structure_box.ts:20-127` · 날개벽 `cu/culvert_sets.ts:387 · 391` · 포장 물넘이 겹침 `B05/UI_Structures_Pavement.ts:41`(콘크리트 포장 키 직접 `Panel_Commit.ts:365`) · 대피로 상세 asRoad. +- 어긋남 — 기슭막이 최소 덮개 TS 1.5 m(`B05/UI_Profile_MinCover.ts:61`) · 파이썬 0(`cu/drainage_pipes.py:554`). + +## 7. 요구 219 흠 (요약) + +- 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물 = 저장 전 카드 이름 · 관 없음(`B06/Section_Drafts.ts:222 · 226 · 233-239`) — 코드 확인. +- 쇄석 = 구조물 초안이 있으면 저장 전 포장 지움(`B06/Section_Store.ts:91-92`) · 서버는 G 모두 그림. +- B06 종단 띠 = 관만(`B06/UI_Section_View_Profile.ts:92-110 · 179-193`). +- 서버 같은 측점 이름 = 마지막 것(`B05/Engine_Sections_Core.py:338-341`) · 낡음 판정 = 자리만(`B06/Router_Stations.py:139-154`). +- 그리기가 조정값을 세션에 적음(`B06/UI_Page_Station_Controls.ts:280-286`) — 재현 전. +- 같은 정본 종류 바꿈은 구조물 쪽 조정값 안 버림(`B05/UI_Structures_Panel_Events.ts:136` 관만) · E · F 점형 ↔ 구간형 조용히 되돌림(`Panel_Events.ts:126-139` · `Panel_Commit.ts:287-317`). +- 3D 해시 = 손으로 고른 칸(`B05/UI_Corridor_Structures_Hash.ts`) · B05 restoreSections 은 처음 · 노선 읽을 때만. diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2016_상반_산림청_사방사업표준품셈(안).xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2016_상반_산림청_사방사업표준품셈(안).xls new file mode 100644 index 000000000..d26ab3e5a Binary files /dev/null and b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2016_상반_산림청_사방사업표준품셈(안).xls differ diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026_상반_산림청_수목진료.xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026_상반_산림청_수목진료.xls new file mode 100644 index 000000000..e826a94ce Binary files /dev/null and b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026_상반_산림청_수목진료.xls differ diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls new file mode 100644 index 000000000..ea8ac586c Binary files /dev/null and b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls differ diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls new file mode 100644 index 000000000..77e14337d Binary files /dev/null and b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls differ diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index a87b28d50..94b5b954d 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 20, + "판": 21, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -32,13 +32,6 @@ "color": "#4f8ef7", "abbr": "배수관" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "detail", - "quantity" - ], "options": [ { "key": "pipe_kind", @@ -299,13 +292,6 @@ "color": "#3b6fd4", "abbr": "암거" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "detail", - "quantity" - ], "options": [ { "key": "body_width_m", @@ -478,13 +464,6 @@ "color": "#2f9e8f", "abbr": "물넘이" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "detail", - "quantity" - ], "options": [ { "key": "ford_slope_deg", @@ -545,13 +524,6 @@ "color": "#28857a", "abbr": "세월교" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "detail", - "quantity" - ], "options": [ { "key": "pipe_kind", @@ -679,12 +651,6 @@ "color": "#5aa7e8", "abbr": "횡수로" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "quantity" - ], "options": [] }, { @@ -696,12 +662,6 @@ "color": "#6d9bd1", "abbr": "개거" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "quantity" - ], "options": [] }, { @@ -713,19 +673,13 @@ "color": "#3aa0a8", "abbr": "횡맹" }, - "drawing_views": [ - "plan", - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "culvert_height_m", "label": "암거 높이", "input": "number", "unit": "m", - "default": null, + "default": 3, "required": false, "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" }, @@ -734,7 +688,7 @@ "label": "암거 경사", "input": "number", "unit": "%", - "default": null, + "default": 3, "required": false, "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" } @@ -751,11 +705,6 @@ "color": "#2eaadc", "abbr": "측구" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "form", @@ -790,11 +739,6 @@ "color": "#2e8bdc", "abbr": "산마루" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "material", @@ -821,10 +765,6 @@ "color": "#2ec5dc", "abbr": "소단구" }, - "drawing_views": [ - "cross_section", - "quantity" - ], "options": [ { "key": "length_m", @@ -892,11 +832,6 @@ "color": "#1f9bb8", "abbr": "도수로" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "material", @@ -939,10 +874,6 @@ "color": "#28b1a1", "abbr": "사면로" }, - "drawing_views": [ - "cross_section", - "quantity" - ], "options": [ { "key": "material", @@ -968,11 +899,6 @@ "color": "#1f7fb8", "abbr": "맹암거" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "note", @@ -991,12 +917,6 @@ "color": "#e07a2e", "abbr": "옹벽" }, - "drawing_views": [ - "profile", - "cross_section", - "detail", - "quantity" - ], "options": [ { "key": "wall_form", @@ -1105,11 +1025,6 @@ "color": "#d0692a", "abbr": "찰쌓기" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "height_m", @@ -1328,11 +1243,6 @@ "color": "#c25d2c", "abbr": "메쌓기" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "height_m", @@ -1553,11 +1463,6 @@ "color": "#b8742e", "abbr": "흙막이" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "form", @@ -1809,11 +1714,6 @@ "color": "#a86a2a", "abbr": "큰돌" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "height_m", @@ -1998,11 +1898,6 @@ "color": "#7f9a63", "abbr": "소단" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "width_m", @@ -2018,7 +1913,7 @@ "label": "소단 위치(도로 아래)", "input": "number", "unit": "m", - "default": 1.0, + "default": 1, "required": false, "phase": "b05", "default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)" @@ -2088,12 +1983,6 @@ "color": "#7a52c7", "abbr": "골막이" }, - "drawing_views": [ - "plan", - "profile", - "detail", - "quantity" - ], "options": [ { "key": "form", @@ -2300,11 +2189,6 @@ "color": "#7c5f3b", "abbr": "바닥" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "form", @@ -2410,12 +2294,6 @@ "color": "#6b46b8", "abbr": "사방댐" }, - "drawing_views": [ - "plan", - "profile", - "detail", - "quantity" - ], "options": [ { "key": "height_m", @@ -2438,11 +2316,6 @@ "color": "#9b6bdc", "abbr": "기슭" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "side", @@ -2711,10 +2584,6 @@ "color": "#2fa84f", "abbr": "대피로" }, - "drawing_views": [ - "plan", - "quantity" - ], "options": [ { "key": "detail", @@ -2741,11 +2610,6 @@ "color": "#2fa84f", "abbr": "대피소" }, - "drawing_views": [ - "plan", - "cross_section", - "quantity" - ], "options": [ { "key": "width_m", @@ -2774,11 +2638,6 @@ "color": "#268f43", "abbr": "작업장" }, - "drawing_views": [ - "plan", - "cross_section", - "quantity" - ], "options": [ { "key": "width_m", @@ -2805,10 +2664,6 @@ "color": "#37b95a", "abbr": "차돌림" }, - "drawing_views": [ - "plan", - "quantity" - ], "options": [ { "key": "width_m", @@ -2828,9 +2683,6 @@ "color": "#43c76b", "abbr": "반사경" }, - "drawing_views": [ - "plan" - ], "options": [] }, { @@ -2842,9 +2694,6 @@ "color": "#4fae5c", "abbr": "경계석" }, - "drawing_views": [ - "plan" - ], "options": [] }, { @@ -2856,9 +2705,6 @@ "color": "#5fb83a", "abbr": "위험" }, - "drawing_views": [ - "plan" - ], "options": [] }, { @@ -2870,11 +2716,6 @@ "color": "#1f9e46", "abbr": "가드" }, - "drawing_views": [ - "plan", - "cross_section", - "quantity" - ], "options": [ { "key": "form", @@ -2918,10 +2759,6 @@ "color": "#53a832", "abbr": "번호판" }, - "drawing_views": [ - "plan", - "detail" - ], "options": [] }, { @@ -2933,9 +2770,6 @@ "color": "#3a9e78", "abbr": "취수정" }, - "drawing_views": [ - "plan" - ], "options": [] }, { @@ -2947,10 +2781,6 @@ "color": "#3f9e2e", "abbr": "차단기" }, - "drawing_views": [ - "plan", - "detail" - ], "options": [] }, { @@ -2962,9 +2792,6 @@ "color": "#66b344", "abbr": "준공" }, - "drawing_views": [ - "plan" - ], "options": [] }, { @@ -2976,11 +2803,6 @@ "color": "#8c9a2e", "abbr": "사토장" }, - "drawing_views": [ - "plan", - "cross_section", - "quantity" - ], "options": [ { "key": "capacity_m3", @@ -3003,10 +2825,6 @@ "color": "#7d8a2a", "abbr": "토취장" }, - "drawing_views": [ - "plan", - "quantity" - ], "options": [ { "key": "capacity_m3", @@ -3030,10 +2848,6 @@ "color": "#3aa88f", "abbr": "동물길" }, - "drawing_views": [ - "plan", - "cross_section" - ], "options": [ { "key": "form", @@ -3059,10 +2873,6 @@ "color": "#4cb84c", "abbr": "녹화" }, - "drawing_views": [ - "cross_section", - "quantity" - ], "options": [ { "key": "method", @@ -3180,11 +2990,6 @@ "color": "#8a8f98", "abbr": "포장" }, - "drawing_views": [ - "profile", - "cross_section", - "quantity" - ], "options": [ { "key": "height_m", @@ -3273,10 +3078,6 @@ "color": "#9aa0a8", "abbr": "쇄석" }, - "drawing_views": [ - "cross_section", - "quantity" - ], "options": [ { "key": "thickness_cm", @@ -3298,9 +3099,6 @@ "color": "#98a2b3", "abbr": "기타" }, - "drawing_views": [ - "profile" - ], "options": [ { "key": "name", diff --git a/resources/master_template/구조물정의_이력/판_20.json b/resources/master_template/구조물정의_이력/판_20.json new file mode 100644 index 000000000..a87b28d50 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_20.json @@ -0,0 +1,3819 @@ +{ + "판": 20, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "집수정", + "required": false, + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_height_m", + "label": "집수정 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)" + }, + { + "key": "inlet_revet_form", + "label": "유입 흙막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 흙막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 흙막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 흙막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 흙막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 높이(관경을 0.1m 로 올림 + 근입 0.5 · 벽 윗면 = 관 윗면)로 그리고 셈 — 횡단도·구조물도·표가 같은 값(139-1)", + "default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 흙막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 흙막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 흙막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 흙막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 흙막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 높이(관경을 0.1m 로 올림 + 근입 0.5 · 벽 윗면 = 관 윗면)로 그리고 셈 — 횡단도·구조물도·표가 같은 값(139-1)", + "default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)" + }, + { + "key": "revet_foundation", + "label": "흙막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "흙막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "유입 구조", + "input": "select", + "choices": [ + "없음", + "날개벽", + "흙막이" + ], + "default": "날개벽", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "유출 구조", + "input": "select", + "choices": [ + "없음", + "날개벽", + "흙막이" + ], + "default": "날개벽", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_slope_deg", + "label": "경사각도", + "input": "number", + "unit": "°", + "default": 3, + "required": false, + "default_basis": "물넘이 포장 경사각 기본 3° — 사용자 확정값(PLAN 65-5)" + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 10 m — 사용자 확정 10-7(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": 2, + "required": false, + "default_basis": "세월교 배관 수량 기본 2련 — 사용자 확정값(PLAN 65-4)" + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "cross_underdrain", + "group": "A", + "name": "맹암거", + "placement": "point", + "style": { + "color": "#3aa0a8", + "abbr": "횡맹" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "culvert_height_m", + "label": "암거 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + }, + { + "key": "culvert_slope_pct", + "label": "암거 경사", + "input": "number", + "unit": "%", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "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" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "wall_form", + "label": "형태", + "input": "select", + "choices": [ + "콘크리트" + ], + "default": "콘크리트", + "default_basis": "표 09 — 선택지 하나(콘크리트)", + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "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": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "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": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "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": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "empty_means": "비우면 통나무·목재틀로 그림(횡단 기본 형태) — B05 로 옮긴 칸이라 필수로 안 막음(옛 흙막이 저장 보호)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "b05", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "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": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "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": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "소단 폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "depth_m", + "label": "소단 위치(도로 아래)", + "input": "number", + "unit": "m", + "default": 1.0, + "required": false, + "phase": "b05", + "default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)" + }, + { + "key": "height_m", + "label": "소단 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비워도 놓임 — 아직 계산(소단 간격 · 횡단)에 안 쓰임(표 09 새 칸)" + }, + { + "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": "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": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "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": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "evac_road", + "group": "E", + "name": "대피로", + "placement": "point", + "style": { + "color": "#2fa84f", + "abbr": "대피로" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "detail", + "label": "상세", + "input": "select", + "choices": [ + "대피소", + "정차/작업장", + "차돌림곳" + ], + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 상세 미정 — 대피로 자리만 잡음(대피소 · 정차/작업장 · 차돌림곳은 뒤에 고름)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "curb", + "group": "E", + "name": "경계석", + "placement": "point", + "style": { + "color": "#4fae5c", + "abbr": "경계석" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "hazard_sign", + "group": "E", + "name": "위험표시", + "placement": "point", + "style": { + "color": "#5fb83a", + "abbr": "위험" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "text", + "default": null, + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 범위를 못 셈 — 길이를 적어야 기준 측점 앞뒤로 구간이 섬" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 0 — 기준 측점이 곧 시작(길이 = 전 + 후 맞물림)" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "water_intake", + "group": "E", + "name": "취수정", + "placement": "point", + "style": { + "color": "#3a9e78", + "abbr": "취수정" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(사토장)", + "placement": "site", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "사토장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 B06 사토 배분이 이 사토장을 건너뜀" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "토취장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 자리만 잡음 — 부피는 설계자가 현장 조사로 손 입력" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.2, + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 10-8 · 임도기술교본 3-2 「두께 0.2m」 기준 · 명세 칸(상세 내용)이 받음" + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "상세 종류 이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.animal": "동물이동형", + "inlet.animal=true": "유", + "inlet.animal=false": "무", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)", + "chute_add.bond": "형태", + "chute_add.bond=찰붙임": "찰붙임", + "chute_add.bond=메붙임": "메붙임", + "chute_add.area": "면적 (㎡)" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ], + "이름": { + "body.size": "본체 규격", + "body.size=2.0×2.0": "2.0 × 2.0", + "body.size=3.0×3.0": "3.0 × 3.0", + "body.size=사용자 입력": "사용자 입력", + "body.width": "가로 (m)", + "body.height": "세로 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.berm": "수평공간 (m)", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)", + "chute_add.bond": "형태", + "chute_add.bond=찰붙임": "찰붙임", + "chute_add.bond=메붙임": "메붙임", + "chute_add.area": "면적 (㎡)" + } + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.count": "수량 (련)", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.berm": "수평공간 (m)", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)", + "chute_add.bond": "형태", + "chute_add.bond=찰붙임": "찰붙임", + "chute_add.bond=메붙임": "메붙임", + "chute_add.area": "면적 (㎡)" + } + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + }, + "soil_guard": { + "이름": { + "form=돌쌓기(찰)": "돌 쌓기(찰)", + "form=돌쌓기(메)": "돌 쌓기(메)", + "form=콘크리트": "콘크리트", + "form=돌망태": "돌망태", + "form=통나무·목재틀": "통나무/목재틀", + "form=바자": "바자" + } + } + }, + "표시": { + "pipe": { + "pipe_kind": false + }, + "soil_guard": { + "side": false, + "tiers": false, + "lift_m": false, + "shift_m": false, + "thickness_top_m": false, + "thickness_bottom_m": false, + "back_len_cm": false, + "stone_kind": false, + "stone_supply": false, + "stone_coeff_basis": false, + "fill_concrete_mpa": false, + "face_slope_ratio": false, + "foundation": false, + "weep_hole_diameter_mm": false, + "weep_hole_area_m2": false + }, + "retaining_wall": { + "form": false, + "side": false, + "weep_hole_diameter_mm": false, + "weep_hole_area_m2": false + }, + "berm": { + "depth_m": false, + "height_m": false, + "length_m": false, + "before_m": false, + "after_m": false, + "interval_m": false, + "slope_deg": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "evac_road": [ + { + "id": "gevacdetail", + "이름": "상세 내용", + "옵션": [ + "detail" + ], + "형상": {} + } + ], + "guardrail": [ + { + "id": "gguarddetail", + "이름": "상세 내용", + "옵션": [ + "form", + "length_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "box_culvert": [ + { + "id": "gboxbody", + "이름": "본체", + "옵션": [ + "body_width_m", + "body_height_m" + ], + "형상": {} + }, + { + "id": "gboxin", + "이름": "유입 구조", + "옵션": [ + "wing_in" + ], + "형상": {} + }, + { + "id": "gboxout", + "이름": "유출 구조", + "옵션": [ + "wing_out" + ], + "형상": {} + } + ], + "ford_pavement": [ + { + "id": "gfordpaveslope", + "이름": "경사각도", + "옵션": [], + "형상": {} + } + ], + "retaining_wall": [ + { + "id": "gretaindetail", + "이름": "옹벽", + "옵션": [ + "wall_form", + "length_m", + "height_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "masonry_wet": [ + { + "id": "gmasonwetdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "masonry_dry": [ + { + "id": "gmasondrydetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "soil_guard": [ + { + "id": "gsoilgdetail", + "이름": "흙막이", + "옵션": [ + "form", + "length_m", + "height_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "boulder_masonry": [ + { + "id": "gboulderdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "stone_cm", + "bond", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "berm": [ + { + "id": "gbermdetail", + "이름": "상세 내용", + "옵션": [ + "width_m" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/golden_322_structures.json b/resources/tester/golden_322_structures.json new file mode 100644 index 000000000..05815ecc9 --- /dev/null +++ b/resources/tester/golden_322_structures.json @@ -0,0 +1,2677 @@ +{ + "★ 메모": [ + "★1 노출형 · 개거 횡단도 none(형상 none → 횡단도 none) — 측점 · 카드 안 생김", + "★2 쇄석(자갈) 포설 = 형상 pavement — 화면 초안 얹기에서도 포장층(「사용 중」 거르기 없앰)", + "★3 기슭막이 표 자리꼴 interval(정의 파일 그대로) — 표 줄만 바뀜 · 알약 띠는 관 지점이라 그대로 · 거울 시험이 지킴", + "★4 코드 숨김 10종(돌쌓기 셋 · D군 넷 · 옛 대피 셋) = 목록 hidden — 옛 대피 셋만 고르기 목록에서 빠짐 → 가림", + "★5 관 공식 한 벌(322-1 카드 이름) — 빈칸 기본값 = 정의 기본값 · 표시 이름이 정의 관종도 앎" + ], + "★": [ + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/main", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "cross_drain_exposed/blank", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/main", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "station", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "cards", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "station_py", + "단계": 1, + "다음": [], + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "open_ditch/blank", + "column": "cards_py", + "단계": 1, + "다음": {}, + "까닭": "★1 노출형 · 개거 횡단도 none" + }, + { + "part": "samples", + "key": "gravel_surfacing/main", + "column": "cards", + "단계": 1, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + }, + "까닭": "★2 쇄석 화면에서도 포장층" + }, + { + "part": "samples", + "key": "gravel_surfacing/blank", + "column": "cards", + "단계": 1, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + }, + "까닭": "★2 쇄석 화면에서도 포장층" + }, + { + "part": "pick", + "key": "refuge", + "column": "b05", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "refuge", + "column": "b06", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "work_yard", + "column": "b05", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "work_yard", + "column": "b06", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "turnaround", + "column": "b05", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "pick", + "key": "turnaround", + "column": "b06", + "단계": 1, + "다음": "hidden", + "까닭": "★4 코드 숨김 → 목록 hidden(화면 변화 없음 · 빠짐 → 가림)" + }, + { + "part": "samples", + "key": "pipe/blank", + "column": "station_py", + "단계": 1, + "다음": [ + "파형강관 D1000@101.00" + ], + "까닭": "★5 관 공식 한 벌 — 빈칸 = 정의 기본값(파이썬 D800 → D1000)" + }, + { + "part": "samples", + "key": "pipe/blank", + "column": "cards_py", + "단계": 1, + "다음": { + "101.00": { + "title": "파형강관 D1000", + "shape": {} + } + }, + "까닭": "★5 관 공식 한 벌 — 빈칸 = 정의 기본값(파이썬 D800 → D1000)" + }, + { + "part": "samples", + "key": "pipe/hume", + "column": "cards", + "단계": 1, + "다음": { + "101.00": { + "title": "흄관 D800", + "display": "배수관", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + }, + "까닭": "★5 관 공식 한 벌 — 표시 이름도 정의 관종을 앎" + }, + { + "part": "samples", + "key": "ford_pavement/main", + "column": "cards", + "단계": 4, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)" + }, + { + "part": "samples", + "key": "ford_pavement/blank", + "column": "cards", + "단계": 4, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)" + } + ], + "samples": { + "pipe/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "파형강관 D1000", + "display": "배수관", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + }, + "weep": false, + "model3d": [ + "pipe:pipe", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "파형강관 D1000@101.00" + ], + "cards_py": { + "101.00": { + "title": "파형강관 D1000", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + } + }, + "pipe/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "파형강관 D1000", + "display": "배수관", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "파형강관 D800@101.00" + ], + "cards_py": { + "101.00": { + "title": "파형강관 D800", + "shape": {} + } + } + }, + "pipe/hume": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "흄관 D800", + "display": "흄관 D800", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + }, + "weep": false, + "model3d": [ + "pipe:pipe", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "흄관 D800@101.00" + ], + "cards_py": { + "101.00": { + "title": "흄관 D800", + "shape": { + "culvert": { + "hidden_pipe": false, + "body": false + } + } + } + } + }, + "box_culvert/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "BOX암거", + "display": "BOX암거", + "shape": { + "box": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:left:apron", + "basin:right:apron", + "revet:left:wing", + "revet:right:wing" + ], + "band": 0, + "station_py": [ + "BOX암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "BOX암거", + "shape": { + "box": true + } + } + } + }, + "box_culvert/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "BOX암거", + "display": "BOX암거", + "shape": { + "box": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:left:apron", + "basin:right:apron", + "revet:left:wing", + "revet:right:wing" + ], + "band": 0, + "station_py": [ + "BOX암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "BOX암거", + "shape": { + "box": true + } + } + } + }, + "ford_pavement/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "물넘이포장@101.00" + ], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + } + }, + "ford_pavement/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "물넘이포장@101.00" + ], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + } + }, + "ford_bridge/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "세월교", + "display": "세월교", + "shape": { + "ford": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:inlet:apron", + "basin:inlet:floor", + "basin:inlet:wall", + "basin:outlet:apron", + "basin:outlet:floor", + "basin:outlet:wall", + "pipe:pipe", + "revet:inlet:wing", + "revet:outlet:wing" + ], + "band": 0, + "station_py": [ + "세월교@101.00" + ], + "cards_py": { + "101.00": { + "title": "세월교", + "shape": { + "ford": true + } + } + } + }, + "ford_bridge/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "세월교", + "display": "세월교", + "shape": { + "ford": true + } + } + }, + "weep": false, + "model3d": [ + "basin:body", + "basin:inlet:apron", + "basin:inlet:floor", + "basin:inlet:wall", + "basin:outlet:apron", + "basin:outlet:floor", + "basin:outlet:wall", + "pipe:pipe", + "revet:inlet:wing", + "revet:outlet:wing" + ], + "band": 0, + "station_py": [ + "세월교@101.00" + ], + "cards_py": { + "101.00": { + "title": "세월교", + "shape": { + "ford": true + } + } + } + }, + "cross_drain_exposed/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "노출형 횡단수로", + "display": "노출형 횡단수로", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "노출형 횡단수로@101.00" + ], + "cards_py": { + "101.00": { + "title": "노출형 횡단수로", + "shape": {} + } + } + }, + "cross_drain_exposed/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "노출형 횡단수로", + "display": "노출형 횡단수로", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "노출형 횡단수로@101.00" + ], + "cards_py": { + "101.00": { + "title": "노출형 횡단수로", + "shape": {} + } + } + }, + "open_ditch/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "개거(겉도랑)", + "display": "개거(겉도랑)", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "개거(겉도랑)@101.00" + ], + "cards_py": { + "101.00": { + "title": "개거(겉도랑)", + "shape": {} + } + } + }, + "open_ditch/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "개거(겉도랑)", + "display": "개거(겉도랑)", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "개거(겉도랑)@101.00" + ], + "cards_py": { + "101.00": { + "title": "개거(겉도랑)", + "shape": {} + } + } + }, + "cross_underdrain/main": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "맹암거", + "display": "맹암거", + "shape": { + "underdrain": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "맹암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "맹암거", + "shape": { + "underdrain": true + } + } + } + }, + "cross_underdrain/blank": { + "station": [ + "anchor@101.00" + ], + "cards": { + "101.00": { + "title": "맹암거", + "display": "맹암거", + "shape": { + "underdrain": true + } + } + }, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [ + "맹암거@101.00" + ], + "cards_py": { + "101.00": { + "title": "맹암거", + "shape": { + "underdrain": true + } + } + } + }, + "ditch_side/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_side/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_ridge/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_ridge/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_berm/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "ditch_berm/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "chute/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "chute/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "slope_drain/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "slope_drain/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "underdrain/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "underdrain/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "retaining_wall/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + }, + "weep": true, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "옹벽@93.00", + "옹벽@101.00", + "옹벽@113.00" + ], + "cards_py": { + "93.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + } + }, + "retaining_wall/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "display": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "옹벽@93.00", + "옹벽@101.00", + "옹벽@113.00" + ], + "cards_py": { + "93.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "101.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + }, + "113.00": { + "title": "옹벽", + "shape": { + "revetment": { + "type_id": "retaining_wall", + "form": "콘크리트" + } + } + } + } + }, + "masonry_wet/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "돌쌓기(찰)@93.00", + "돌쌓기(찰)@101.00", + "돌쌓기(찰)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + } + }, + "masonry_wet/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "display": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "돌쌓기(찰)@93.00", + "돌쌓기(찰)@101.00", + "돌쌓기(찰)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "돌쌓기(찰)", + "shape": { + "revetment": { + "type_id": "masonry_wet", + "form": "돌쌓기(찰)" + } + } + } + } + }, + "masonry_dry/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "돌쌓기(메)@93.00", + "돌쌓기(메)@101.00", + "돌쌓기(메)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "masonry_dry/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "display": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "돌쌓기(메)@93.00", + "돌쌓기(메)@101.00", + "돌쌓기(메)@113.00" + ], + "cards_py": { + "93.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "돌쌓기(메)", + "shape": { + "revetment": { + "type_id": "masonry_dry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "soil_guard/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "흙막이@93.00", + "흙막이@101.00", + "흙막이@113.00" + ], + "cards_py": { + "93.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "101.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + }, + "113.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "돌쌓기(찰)" + } + } + } + } + }, + "soil_guard/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "101.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "113.00": { + "title": "흙막이", + "display": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "흙막이@93.00", + "흙막이@101.00", + "흙막이@113.00" + ], + "cards_py": { + "93.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "101.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + }, + "113.00": { + "title": "흙막이", + "shape": { + "revetment": { + "type_id": "soil_guard", + "form": "통나무·목재틀" + } + } + } + } + }, + "boulder_masonry/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [ + "revet:own" + ], + "band": 1, + "station_py": [ + "큰돌쌓기@93.00", + "큰돌쌓기@101.00", + "큰돌쌓기@113.00" + ], + "cards_py": { + "93.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "boulder_masonry/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "display": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "display": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "큰돌쌓기@93.00", + "큰돌쌓기@101.00", + "큰돌쌓기@113.00" + ], + "cards_py": { + "93.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "100.00": { + "title": null, + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "101.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + }, + "113.00": { + "title": "큰돌쌓기", + "shape": { + "revetment": { + "type_id": "boulder_masonry", + "form": "돌쌓기(메)" + } + } + } + } + }, + "berm/main": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "display": "소단", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "소단@93.00", + "소단@101.00", + "소단@113.00" + ], + "cards_py": { + "93.00": { + "title": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "shape": {} + } + } + }, + "berm/blank": { + "station": [ + "start@93.00", + "anchor@101.00", + "end@113.00" + ], + "cards": { + "93.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "display": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "display": "소단", + "shape": {} + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [ + "소단@93.00", + "소단@101.00", + "소단@113.00" + ], + "cards_py": { + "93.00": { + "title": "소단", + "shape": {} + }, + "101.00": { + "title": "소단", + "shape": {} + }, + "113.00": { + "title": "소단", + "shape": {} + } + } + }, + "erosion_check/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "erosion_check/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "bed_sill/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "bed_sill/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "check_dam_small/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "check_dam_small/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "revetment/main": { + "station": [ + "start@96.00", + "anchor@101.00", + "end@106.00" + ], + "cards": { + "96.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + } + }, + "weep": false, + "model3d": [ + "revet:inlet", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "기슭막이@96.00", + "기슭막이@101.00", + "기슭막이@106.00" + ], + "cards_py": { + "96.00": { + "title": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "shape": {} + } + } + }, + "revetment/blank": { + "station": [ + "start@96.00", + "anchor@101.00", + "end@106.00" + ], + "cards": { + "96.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "display": "기슭막이", + "shape": {} + } + }, + "weep": false, + "model3d": [ + "revet:inlet", + "revet:outlet" + ], + "band": 0, + "station_py": [ + "기슭막이@96.00", + "기슭막이@101.00", + "기슭막이@106.00" + ], + "cards_py": { + "96.00": { + "title": "기슭막이", + "shape": {} + }, + "101.00": { + "title": "기슭막이", + "shape": { + "culvert": { + "hidden_pipe": true, + "body": false + } + } + }, + "106.00": { + "title": "기슭막이", + "shape": {} + } + } + }, + "evac_road/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "evac_road/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "refuge/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "refuge/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "work_yard/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "work_yard/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "turnaround/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "turnaround/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "mirror/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "mirror/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "curb/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "curb/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "hazard_sign/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "hazard_sign/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "guardrail/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "guardrail/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "position_sign/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "position_sign/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "water_intake/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "water_intake/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "barrier_gate/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "barrier_gate/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "completion_sign/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "completion_sign/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "spoil_bank/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "spoil_bank/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "borrow_pit/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "borrow_pit/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "wildlife_path/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "wildlife_path/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "revegetation/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "revegetation/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": {} + }, + "pavement_concrete/main": { + "station": [], + "cards": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + } + }, + "pavement_concrete/blank": { + "station": [], + "cards": { + "100.00": { + "title": null, + "display": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + }, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "pavement_concrete" + ] + } + } + } + }, + "gravel_surfacing/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + } + }, + "gravel_surfacing/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 1, + "station_py": [], + "cards_py": { + "100.00": { + "title": null, + "shape": { + "pavement": [ + "gravel_surfacing" + ] + } + } + } + }, + "etc/main": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + }, + "etc/blank": { + "station": [], + "cards": {}, + "weep": false, + "model3d": [], + "band": 0, + "station_py": [], + "cards_py": {} + } + }, + "pick": { + "pipe": { + "b05": "shown", + "b06": "shown" + }, + "box_culvert": { + "b05": "shown", + "b06": "shown" + }, + "ford_pavement": { + "b05": "shown", + "b06": "shown" + }, + "ford_bridge": { + "b05": "shown", + "b06": "shown" + }, + "cross_drain_exposed": { + "b05": "shown", + "b06": "shown" + }, + "open_ditch": { + "b05": "shown", + "b06": "shown" + }, + "cross_underdrain": { + "b05": "shown", + "b06": "shown" + }, + "ditch_side": { + "b05": "hidden", + "b06": "hidden" + }, + "ditch_ridge": { + "b05": "hidden", + "b06": "hidden" + }, + "ditch_berm": { + "b05": "hidden", + "b06": "hidden" + }, + "chute": { + "b05": "hidden", + "b06": "hidden" + }, + "slope_drain": { + "b05": "hidden", + "b06": "hidden" + }, + "underdrain": { + "b05": "hidden", + "b06": "hidden" + }, + "retaining_wall": { + "b05": "shown", + "b06": "shown" + }, + "masonry_wet": { + "b05": "hidden", + "b06": "hidden" + }, + "masonry_dry": { + "b05": "hidden", + "b06": "hidden" + }, + "soil_guard": { + "b05": "shown", + "b06": "shown" + }, + "boulder_masonry": { + "b05": "hidden", + "b06": "hidden" + }, + "berm": { + "b05": "shown", + "b06": "shown" + }, + "erosion_check": { + "b05": "hidden", + "b06": "hidden" + }, + "bed_sill": { + "b05": "hidden", + "b06": "hidden" + }, + "check_dam_small": { + "b05": "hidden", + "b06": "hidden" + }, + "revetment": { + "b05": "hidden", + "b06": "hidden" + }, + "evac_road": { + "b05": "shown", + "b06": "shown" + }, + "refuge": { + "b05": "absent", + "b06": "absent" + }, + "work_yard": { + "b05": "absent", + "b06": "absent" + }, + "turnaround": { + "b05": "absent", + "b06": "absent" + }, + "mirror": { + "b05": "shown", + "b06": "shown" + }, + "curb": { + "b05": "shown", + "b06": "shown" + }, + "hazard_sign": { + "b05": "shown", + "b06": "shown" + }, + "guardrail": { + "b05": "shown", + "b06": "shown" + }, + "position_sign": { + "b05": "shown", + "b06": "shown" + }, + "water_intake": { + "b05": "shown", + "b06": "shown" + }, + "barrier_gate": { + "b05": "shown", + "b06": "shown" + }, + "completion_sign": { + "b05": "shown", + "b06": "shown" + }, + "spoil_bank": { + "b05": "shown", + "b06": "shown" + }, + "borrow_pit": { + "b05": "shown", + "b06": "shown" + }, + "wildlife_path": { + "b05": "hidden", + "b06": "shown" + }, + "revegetation": { + "b05": "hidden", + "b06": "shown" + }, + "pavement_concrete": { + "b05": "shown", + "b06": "shown" + }, + "gravel_surfacing": { + "b05": "hidden", + "b06": "shown" + }, + "etc": { + "b05": "shown", + "b06": "shown" + } + } +} diff --git a/resources/tester/helper_107_ford_free.cjs b/resources/tester/helper_107_ford_free.cjs index 884f1d5fa..5ee26f04f 100644 --- a/resources/tester/helper_107_ford_free.cjs +++ b/resources/tester/helper_107_ford_free.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -30,6 +31,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; for (const [prefix, dir] of Object.entries(alias)) diff --git a/resources/tester/helper_116_right_click_add.cjs b/resources/tester/helper_116_right_click_add.cjs index d13906f8a..8ee4931ea 100644 --- a/resources/tester/helper_116_right_click_add.cjs +++ b/resources/tester/helper_116_right_click_add.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -30,6 +31,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); @@ -47,7 +49,17 @@ const { addsAtOnce, finishAdd } = loadTs( const { loadForm } = loadTs(path.join(B05, "B05_Profile_UI_Structures_Panel_Commit.ts")); const entry = (required, value) => ({ required, isEmpty: () => value === "" }); -const type = (type_id, managed_by = null) => ({ type_id, group: "C", managed_by }); +// 동작 = 정의 종류 덧칸(앱의 `withDefinition` 과 같음 · 323-7 종류 키 대신 동작 이름) +const DEFINED = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const behaviors = (typeId) => DEFINED.종류?.[typeId]?.동작 ?? []; +const type = (type_id, managed_by = null) => ({ + type_id, + group: "C", + managed_by, + behaviors: behaviors(type_id), +}); /** 목록 알림 → 페이지가 임시 id 를 붙여 패널 배열 안을 갈아 채우는 것(`withLocalIds` · `setStructures`). */ function fakeCommit(structures, adds) { diff --git a/resources/tester/helper_126_ford_fill.cjs b/resources/tester/helper_126_ford_fill.cjs index 1fe3f5984..1c8d78d64 100644 --- a/resources/tester/helper_126_ford_fill.cjs +++ b/resources/tester/helper_126_ford_fill.cjs @@ -43,7 +43,16 @@ const { fordHeightM } = loadTs( path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"), ); -const type = (type_id) => ({ type_id, group: "A", managed_by: "pipe_points" }); +// 동작 = 정의 종류 덧칸(앱의 `withDefinition` 과 같음 · 323-7 종류 키 대신 동작 이름) +const DEFINED = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const type = (type_id) => ({ + type_id, + group: "A", + managed_by: "pipe_points", + behaviors: DEFINED.종류?.[type_id]?.동작 ?? [], +}); /** 우클릭 → 바로 추가 — 관 지점 정본에 한 번 넘기고 그 측점 관을 고름 · 알림 한 줄. */ async function rightClick(typeId) { @@ -116,7 +125,14 @@ function respond(points, basins, pending) { once: { fordBridge: addsAtOnce(type("ford_bridge"), []), fordPavement: addsAtOnce(type("ford_pavement"), []), - pavement: addsAtOnce({ type_id: "pavement_concrete", managed_by: null }, []), + pavement: addsAtOnce( + { + type_id: "pavement_concrete", + managed_by: null, + behaviors: DEFINED.종류?.pavement_concrete?.동작 ?? [], + }, + [], + ), }, bridge: await rightClick("ford_bridge"), paveRc: await rightClick("ford_pavement"), diff --git a/resources/tester/helper_129_menu_equals_panel.cjs b/resources/tester/helper_129_menu_equals_panel.cjs index 64e9e46b3..aa75ceee4 100644 --- a/resources/tester/helper_129_menu_equals_panel.cjs +++ b/resources/tester/helper_129_menu_equals_panel.cjs @@ -30,6 +30,9 @@ function loadTs(file) { cache.set(full, box.exports); const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + // 구조물 부품 상자(322-1) — JSON 그대로(트랜스파일이 기본 내보내기로 감쌈) + if (request.endsWith(".json")) + return JSON.parse(fs.readFileSync(path.join(path.dirname(full), request), "utf8")); if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); if (request.endsWith(".css")) return {}; @@ -44,16 +47,19 @@ const B05 = path.join(ROOT, "B05_Profile"); const T = loadTs(path.join(B05, "B05_Profile_UI_Structures_Panel_Types.ts")); const M = loadTs(path.join(B05, "B05_Profile_UI_Profile_Structures.ts")); const D = loadTs(path.join(B05, "B05_Profile_UI_Structures_Define.ts")); +const C = loadTs(path.join(ROOT, "common_util", "common_util_structure_catalog.ts")); const read = (file) => JSON.parse(fs.readFileSync(file, "utf8")); const types = D.withDefinition(read(process.argv[2]), read(process.argv[3])); +// 화면 `fetchStructureTypes` 처럼 구조물 표를 정의로 세움(322-1) +C.useCatalogDefinition({ types: read(process.argv[2]), ...read(process.argv[3]) }); /** 패널 드롭다운에 보이는 것 — 군(차례 · 이름) → 그 군 고르면 보이는 종류(차례 · 이름 · id). */ function panelList(pick) { return T.groupOptions(types, pick) .filter((option) => !option.hidden) .map((group) => { - const candidates = types.filter((t) => t.group === group.value && D.pickable(t)); + const candidates = types.filter((t) => t.group === group.value); const kinds = T.typeOptions(candidates, pick).filter((option) => !option.hidden); return { label: group.text, types: kinds.map((option) => [option.text, option.value]) }; }); @@ -72,8 +78,9 @@ function menuList(pick) { })); } -const enabled = (t) => t.enabled; -const all = () => true; +// 페이지 거르기 = 구조물 표 목록(322-1 · B05 = both · B06 = both + b06) +const enabled = C.listPick("b05"); +const all = C.listPick("b06"); process.stdout.write( JSON.stringify({ b05: { panel: panelList(enabled), menu: menuList(enabled) }, diff --git a/resources/tester/helper_136_chute_form_cache.cjs b/resources/tester/helper_136_chute_form_cache.cjs index c06e7abc0..bbbc053dc 100644 --- a/resources/tester/helper_136_chute_form_cache.cjs +++ b/resources/tester/helper_136_chute_form_cache.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -27,6 +28,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); if (request.startsWith("@util/")) return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); diff --git a/resources/tester/helper_139_4_buried_outlet.cjs b/resources/tester/helper_139_4_buried_outlet.cjs index a8d7075b9..34a26c770 100644 --- a/resources/tester/helper_139_4_buried_outlet.cjs +++ b/resources/tester/helper_139_4_buried_outlet.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -23,6 +24,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; for (const [prefix, dir] of Object.entries(alias)) diff --git a/resources/tester/helper_152_3_chute_cut.cjs b/resources/tester/helper_152_3_chute_cut.cjs index 9bbaf3331..9963064cb 100644 --- a/resources/tester/helper_152_3_chute_cut.cjs +++ b/resources/tester/helper_152_3_chute_cut.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -23,6 +24,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; for (const [prefix, dir] of Object.entries(alias)) diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index 147bce9d6..272cd5cbe 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const B07 = path.join(ROOT, "B07_DesignDetail"); @@ -73,6 +74,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (request === "@ui/ui_template_elements") return { ...loadTs(path.join(UI, "ui_template_elements.ts")), diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 156a2845b..3a82fb648 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); @@ -15,6 +16,7 @@ function loadTs(file, stubs = {}) { }).outputText; const box = { exports: {} }; const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (stubs[request]) return stubs[request]; throw new Error(`stub 없음: ${request}`); }; @@ -131,6 +133,8 @@ const panelModule = loadTs( })), structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, writePendingStructures() {}, + dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0) + staleDefinitionNotice: () => null, // 판 알림(322-2) }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null, @@ -163,6 +167,15 @@ const panelModule = loadTs( setAdjustSideSlots() {}, }, "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../B05_Profile/B05_Profile_UI_Structures_Snap": { + snapNotice() {}, + snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }), + }, + "../A00_Common/design/design_pipe_ids": { + pipeIdAt: (at) => `pipe-${at.toFixed(2)}`, + followMovedPipe() {}, + }, // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), diff --git a/resources/tester/helper_166_1_note_options.cjs b/resources/tester/helper_166_1_note_options.cjs index 725dba4cc..2ac2f22df 100644 --- a/resources/tester/helper_166_1_note_options.cjs +++ b/resources/tester/helper_166_1_note_options.cjs @@ -4,6 +4,7 @@ */ const fs = require("fs"); const path = require("path"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); @@ -18,6 +19,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; // 구조물 표(본보기 · 323-7) if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); return require(request); }; diff --git a/resources/tester/helper_173_6_half_width.cjs b/resources/tester/helper_173_6_half_width.cjs index 766c4b026..bec0471b2 100644 --- a/resources/tester/helper_173_6_half_width.cjs +++ b/resources/tester/helper_173_6_half_width.cjs @@ -20,9 +20,9 @@ const STUBS = { }, "./B06_Section_Api_Fetch": { regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), - fetchSectionDetail: async () => ({ cross_sections: [] }), }, - "./B06_Section_Section_Store": { replaceSectionDetail: () => {} }, + // 다시 받기 = 공유 캐시 길(초안 측점 · 그림 자료 · 323-4) + "./B06_Section_Section_Store": { loadSectionDetail: async () => ({ cross_sections: [] }) }, }; const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts"); diff --git a/resources/tester/helper_173_7_inlet_animal.cjs b/resources/tester/helper_173_7_inlet_animal.cjs index 89f9a38be..f6e63557d 100644 --- a/resources/tester/helper_173_7_inlet_animal.cjs +++ b/resources/tester/helper_173_7_inlet_animal.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -105,6 +106,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request.startsWith("@util/")) return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); diff --git a/resources/tester/helper_178_3_slope_tiers.cjs b/resources/tester/helper_178_3_slope_tiers.cjs index 4c155ed27..290e69156 100644 --- a/resources/tester/helper_178_3_slope_tiers.cjs +++ b/resources/tester/helper_178_3_slope_tiers.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -29,6 +30,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; for (const [prefix, dir] of Object.entries(alias)) diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index dd6bae490..a1f70e5b9 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -67,6 +68,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => + (CATALOG.isCatalogRequest(request) ? CATALOG : null) ?? STUBS[request] ?? (request.startsWith(".") ? loadTs(path.join(path.dirname(full), `${request}.ts`)) diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 821a57de2..836b3c577 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); @@ -14,6 +15,7 @@ function loadTs(file, stubs = {}) { }).outputText; const box = { exports: {} }; const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (stubs[request]) return stubs[request]; throw new Error(`stub 없음: ${request}`); }; @@ -150,6 +152,8 @@ const panelModule = loadTs( })), structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0, writePendingStructures() {}, + dropStaleStructureTypes: async () => {}, // 정의 판 확인(322-0) + staleDefinitionNotice: () => null, // 판 알림(322-2) }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null, @@ -182,6 +186,15 @@ const panelModule = loadTs( setAdjustSideSlots() {}, }, "./B06_Section_UI_Page_Structures_Spec": { withSpecDefaults: (o) => o ?? {} }, + // 옛 소수 관 맞춤(322-3 고침) · 관 세션 id(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../B05_Profile/B05_Profile_UI_Structures_Snap": { + snapNotice() {}, + snapPlan: () => ({ structures: [], pipes: [], skipped: 0 }), + }, + "../A00_Common/design/design_pipe_ids": { + pipeIdAt: (at) => `pipe-${at.toFixed(2)}`, + followMovedPipe() {}, + }, // 요구 178 — 알약 측점 짝(진짜 `plantsStation` · 272 측점 심기 한 벌) "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), diff --git a/resources/tester/helper_232_1_3d_press.cjs b/resources/tester/helper_232_1_3d_press.cjs index 26ebb5991..190e3cbde 100644 --- a/resources/tester/helper_232_1_3d_press.cjs +++ b/resources/tester/helper_232_1_3d_press.cjs @@ -73,7 +73,12 @@ const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { const MI = loadTs("B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts", { "./B05_Profile_UI_Markers": MK3, }); -const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +// 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match`(같은 자리 = 고름 모듈 거리) +const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel }); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", { + "../A00_Common/design/design_pick_match": PM, + "../A00_Common/design/design_selection": DSel, +}); const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { "../A00_Common/design/design_selection": DSel, "./B05_Profile_UI_IrregularStations": { diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index 22811c127..29e6833fd 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -39,6 +39,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index f4c8623ca..aacd677f8 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -78,6 +78,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, @@ -85,7 +86,12 @@ const PP = loadTs("A00_Common/design/design_pick_profile.ts", { const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { "@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` }, }); -const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +// 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match` +const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel }); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", { + "../A00_Common/design/design_pick_match": PM, + "../A00_Common/design/design_selection": DSel, +}); const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { "../A00_Common/design/design_selection": DSel, "../A00_Common/design/design_pick_escape": ESC, diff --git a/resources/tester/helper_233_3_release.cjs b/resources/tester/helper_233_3_release.cjs index ff6114e70..6166dfb3a 100644 --- a/resources/tester/helper_233_3_release.cjs +++ b/resources/tester/helper_233_3_release.cjs @@ -55,6 +55,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index 865900800..74e6c98f2 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -52,7 +52,11 @@ function fakeElement(tag) { global.document = { createElement: fakeElement }; const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); -const station = loadTs("common_util/common_util_structure_station.ts"); +// 심는 측점 = 구조물 표(322-1) — 공용 표 모듈(실제 정의로 세움) +const catalog = require("./helper_322_catalog_module.cjs"); +const station = loadTs("common_util/common_util_structure_station.ts", { + "./common_util_structure_catalog": catalog, +}); const STATIONS = input.stations; const out = {}; diff --git a/resources/tester/helper_270_1_cross_map.cjs b/resources/tester/helper_270_1_cross_map.cjs index 9252a7b2a..b73bc2b8f 100644 --- a/resources/tester/helper_270_1_cross_map.cjs +++ b/resources/tester/helper_270_1_cross_map.cjs @@ -42,6 +42,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, // 프로젝트 없음 = 옛 자리 이름(323-5) "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, diff --git a/resources/tester/helper_272_1_station_list.cjs b/resources/tester/helper_272_1_station_list.cjs index d446f7156..a5d9e158c 100644 --- a/resources/tester/helper_272_1_station_list.cjs +++ b/resources/tester/helper_272_1_station_list.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); @@ -13,6 +14,7 @@ function loadTs(file) { }).outputText; const box = { exports: {} }; const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; throw new Error(`stub 없음: ${request} (${file})`); }; new Function("exports", "module", "require", js)(box.exports, box, localRequire); diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 6f7528072..7f3b15bce 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); @@ -13,6 +14,7 @@ function loadTs(file, stubs = {}) { }).outputText; const box = { exports: {} }; new Function("exports", "module", "require", js)(box.exports, box, (name) => { + if (CATALOG.isCatalogRequest(name)) return CATALOG; if (!stubs[name]) throw new Error(`stub 없음: ${name} (${file})`); return stubs[name]; }); @@ -54,6 +56,15 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { }, }, "../common_util/common_util_structure_station": station, + // 형상 얹기 한 벌(323-4) 흉내 — 관 자리 측점에 관 그림 + "@util/common_util_structure_attach": { + attachShapes: (sections, pipes) => { + for (const pipe of pipes) { + const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005); + if (hit) hit.culvert = true; + } + }, + }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_projectId, chainages) => { asked.push(chainages); @@ -84,6 +95,7 @@ const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m); const detail = detailOf(); draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95 await drafts.withDraftStations(detail, "P", 1); + await drafts.withDraftShapes(detail, "P"); // 그림 자료(323-4 · `Section_Store` 차례) const preview = detail.cross_sections.find((s) => s.chainage_m === 95); out.moved = { chainages: chainages(detail), diff --git a/resources/tester/helper_284_1_pill_pick.cjs b/resources/tester/helper_284_1_pill_pick.cjs index 42d219a92..dc349389d 100644 --- a/resources/tester/helper_284_1_pill_pick.cjs +++ b/resources/tester/helper_284_1_pill_pick.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); @@ -14,6 +15,7 @@ function loadTs(file, stubs = {}) { }).outputText; const box = { exports: {} }; const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (stubs[request]) return stubs[request]; throw new Error(`stub 없음: ${request}`); }; diff --git a/resources/tester/helper_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs index 0d0b018cc..2c1e1c3be 100644 --- a/resources/tester/helper_286_1_ford_pavement.cjs +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); @@ -13,6 +14,7 @@ function loadTs(file, stubs = {}) { }).outputText; const box = { exports: {} }; const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (stubs[request]) return stubs[request]; throw new Error(`stub 없음: ${request}`); }; @@ -29,6 +31,14 @@ const ford = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pa { "./B06_Section_UI_Cross_Culvert_Const": {} }, ), "./B06_Section_UI_Cross_Design_Surface": { appendPavementOverlay() {} }, + // 관 세션 id · 신원표(323-5) — 프로젝트 없음 = 옛 자리 이름 + "../A00_Common/design/design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, + "../A00_Common/design/design_pick_match": { + tagShape: (el, id, part) => { + if (id) el.setAttribute("data-structure-id", id); + if (part) el.setAttribute("data-part", part); + }, + }, }); // 286-3 도수로 · 돌붙임 길이 — 유출(좌 · 우가 높음) 성토사면 1:1.5(5 m 내려감 · 사면 길이 √(7.5²+5²) ≈ 9.01) const sets = loadTs(path.join(ROOT, "common_util", "common_util_culvert_sets.ts")); diff --git a/resources/tester/helper_288_underdrain.cjs b/resources/tester/helper_288_underdrain.cjs index 04282f3c8..5a81ab179 100644 --- a/resources/tester/helper_288_underdrain.cjs +++ b/resources/tester/helper_288_underdrain.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -15,6 +16,7 @@ const load = (file) => { const box = { exports: {} }; cache.set(file, box.exports); const near = (m) => { + if (CATALOG.isCatalogRequest(m)) return CATALOG; if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); diff --git a/resources/tester/helper_294_7_ford_slab_pick.cjs b/resources/tester/helper_294_7_ford_slab_pick.cjs index b09b33ccb..b1e235a3e 100644 --- a/resources/tester/helper_294_7_ford_slab_pick.cjs +++ b/resources/tester/helper_294_7_ford_slab_pick.cjs @@ -27,7 +27,7 @@ globalThis.requestAnimationFrame = (fn) => fn(); const STUBS = { "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} }, "./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} }, - "./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" }, + "./B06_Section_UI_Page_Persist": { isBermType: (typeId) => typeId === "berm" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); function loadTs(full) { diff --git a/resources/tester/helper_313_clear_all.cjs b/resources/tester/helper_313_clear_all.cjs index 43801ce9a..118801d67 100644 --- a/resources/tester/helper_313_clear_all.cjs +++ b/resources/tester/helper_313_clear_all.cjs @@ -4,6 +4,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -24,6 +25,7 @@ const load = (file) => { const box = { exports: {} }; cache.set(file, box.exports); const near = (m) => { + if (CATALOG.isCatalogRequest(m)) return CATALOG; if (m.startsWith("@ui/")) return UI; if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); diff --git a/resources/tester/helper_314_stale_structure.cjs b/resources/tester/helper_314_stale_structure.cjs index a660a2372..2e975655c 100644 --- a/resources/tester/helper_314_stale_structure.cjs +++ b/resources/tester/helper_314_stale_structure.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -16,6 +17,7 @@ const load = (file) => { const box = { exports: {} }; cache.set(file, box.exports); const near = (m) => { + if (CATALOG.isCatalogRequest(m)) return CATALOG; if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); return new Proxy({}, { get: () => () => {} }); diff --git a/resources/tester/helper_317_kind_change_station.cjs b/resources/tester/helper_317_kind_change_station.cjs index a26ffa7d0..8b17b7e77 100644 --- a/resources/tester/helper_317_kind_change_station.cjs +++ b/resources/tester/helper_317_kind_change_station.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedStructures: [] }; @@ -32,6 +33,7 @@ const load = (file) => { const box = { exports: {} }; cache.set(file, box.exports); const near = (m) => { + if (CATALOG.isCatalogRequest(m)) return CATALOG; if (STUBS[m]) return STUBS[m]; if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); @@ -41,9 +43,13 @@ const load = (file) => { return box.exports; }; -const { withDraftStations, forgetDraftStations } = load( - path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"), -); +const drafts = load(path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts")); +const { forgetDraftStations } = drafts; +// 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례 · 323-4) +const withDraftStations = async (detail, projectId, routeId) => { + await drafts.withDraftStations(detail, projectId, routeId); + await drafts.withDraftShapes(detail, projectId); +}; const detail = { cross_sections: [ diff --git a/resources/tester/helper_317_stale_preview.cjs b/resources/tester/helper_317_stale_preview.cjs index af6868dc5..2e2fe9ab6 100644 --- a/resources/tester/helper_317_stale_preview.cjs +++ b/resources/tester/helper_317_stale_preview.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); let refreshCalls = 0; @@ -26,6 +27,7 @@ const load = (file) => { const box = { exports: {} }; cache.set(file, box.exports); const near = (m) => { + if (CATALOG.isCatalogRequest(m)) return CATALOG; if (STUBS[m]) return STUBS[m]; if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`)); if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); diff --git a/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 06008298a..d69a3482d 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); @@ -208,6 +209,7 @@ function makeLoader(real, stubs = {}) { const box = { exports: {} }; cache.set(full, box.exports); const need = (spec) => { + if (CATALOG.isCatalogRequest(spec)) return CATALOG; if (spec in stubs) return stubs[spec]; let target = null; if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`); @@ -277,6 +279,7 @@ const out = {}; tick_x: ticks.length ? Number(ticks[0].attrs.x) : null, tick_near_center: ticks.filter((t) => Math.abs(Number(t.attrs.y) - 3 - centerY) < 14).length, tick_count: ticks.length, + on_top: layer.children.slice(-2).map((node) => node.className), // 319-1 상자 · 글 = 맨 뒤(위) hidden_when_out_of_view: draw(800).querySelector(".b06-chart__center-box") === null, absent_without_center: draw(null).querySelector(".b06-chart__center-box") === null, }; diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs new file mode 100644 index 000000000..6eb4ee982 --- /dev/null +++ b/resources/tester/helper_319_kind_swap.cjs @@ -0,0 +1,417 @@ +/* PLAN 319 단위검증 헬퍼 — 정본이 다른 종류로 바꿈(관 지점 ↔ 구조물 · 맹암거 · 개거) = 옛 것 지우고 같은 + * 측점에 새 종류(패널 입력 배선) · 일반 측점 자리(grid) 종류 바꿈 · 지움 = 구조물만 뺀 사본에 새 종류(횡단 초안 맞춤). + * 진짜 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 무거운 화면 모듈은 흉내) · 결과 = 표준출력 JSON 한 벌. + * 짝 = `test_319_kind_swap.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`); +const TYPES = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +).types; + +class FakeStorage { + constructor() { + this.map = new Map(); + } + get length() { + return this.map.size; + } + key(index) { + return [...this.map.keys()][index] ?? null; + } + getItem(key) { + return this.map.has(key) ? this.map.get(key) : null; + } + setItem(key, value) { + this.map.set(key, String(value)); + } + removeItem(key) { + this.map.delete(key); + } +} +globalThis.window = { + sessionStorage: new FakeStorage(), + localStorage: new FakeStorage(), + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + requestAnimationFrame: (fn) => fn(), + setTimeout, + clearTimeout, +}; +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.requestAnimationFrame = (fn) => fn(); +globalThis.document = { activeElement: null, addEventListener: () => {} }; + +const anything = () => + new Proxy({}, { get: (_t, key) => (key === "__esModule" ? false : () => undefined) }); + +/** `real` 에 든 파일만 진짜로 읽고 그 밖 상대 경로는 흉내 · `stubs` 는 지정자 그대로 갈음. */ +function makeLoader(real, stubs = {}) { + const cache = new Map(); + const realSet = new Set(real.map((file) => path.resolve(file))); + const load = (file) => { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + if (CATALOG.isCatalogRequest(spec)) return CATALOG; + if (spec in stubs) return stubs[spec]; + let target = null; + if (spec.startsWith("@util/")) target = path.join(ROOT, "common_util", `${spec.slice(6)}.ts`); + else if (spec.startsWith(".")) target = path.join(path.dirname(full), `${spec}.ts`); + if (target && realSet.has(path.resolve(target))) return load(target); + return anything(); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; + }; + return load; +} + +const out = {}; + +// ── 1. 패널 종류 바꿈(319-2) — 정본 다른 종류 = 지우고 같은 측점 addAt ───────────── +const part1 = (async () => { + const load = makeLoader( + [ + B05("B05_Profile_UI_Structures_Panel_Events"), + B05("B05_Profile_Api_Structures"), + B06("B06_Section_Api_Culvert_Options"), + path.join(ROOT, "A00_Common", "b_page_state.ts"), + path.join(ROOT, "A00_Common", "design", "design_selection.ts"), + path.join(ROOT, "common_util", "common_util_summary_item.ts"), + ], + { + "./B05_Profile_UI_Structures_Define": { asRoad: (record) => record }, + "@ui/ui_template_elements": { + showConfirmDialog: async () => true, + showToast: (text, kind) => toasts.push([text, kind ?? null]), + }, + "./B06_Section_Section_Drafts": { forgetPreviewAdjusts: () => {} }, + }, + ); + const toasts = []; + const { bindStructuresEvents } = load(B05("B05_Profile_UI_Structures_Panel_Events")); + const typeMap = new Map(TYPES.map((type) => [type.type_id, type])); + const el = () => ({ + value: "", + listeners: {}, + addEventListener(t, fn) { + this.listeners[t] = fn; + }, + }); + const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } }); + // commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림) + // addOk = 새 종류가 기본값으로 바로 놓임(패널 `addAt` 이 true) · false = 빈 칸 때문에 폼만(323-6 고침) + const run = async ({ + structures = [], + pipes = [], + editing = null, + pipeAt = null, + next, + commitOk = true, + addOk = true, + }) => { + toasts.length = 0; + const calls = { + loaded: [], + removed: [], + addAt: [], + emit: 0, + live: 0, + reset: 0, + editingSet: [], + pipeSet: [], + }; + let editingId = editing; + let selectedPipe = pipeAt; + const ctx = { + groupSelect: el(), + typeSelect: el(), + primary: el(), + removeButton: el(), + resetButton: el(), + positionRow: el(), + memoField: el(), + optionRow: el(), + optionInputs: [], + anchorFields: fields(), + startFields: fields(), + endFields: fields(), + facilityOptions: { root: el(), readOptions: () => ({}) }, + groupBoxes: { shapes: () => ({}), get: () => ({}), set: () => {}, setShapes: () => {} }, + structures, + projectId: "P", + callbacks: { + onPipeRemove: (at) => calls.removed.push(at), + onListRelease: () => {}, + }, + pipeFacilities: () => pipes, + types: () => TYPES, + typeMap: () => typeMap, + currentType: () => (next ? typeMap.get(next) : null), + editingId: () => editingId, + setEditingId: (value) => { + editingId = value; + calls.editingSet.push(value); + }, + selectedPipeChainage: () => selectedPipe, + setSelectedPipeChainage: (value) => { + selectedPipe = value; + calls.pipeSet.push(value); + }, + summary: () => null, + setSummary: () => {}, + cancelTempPipe: () => {}, + resetForm: () => (calls.reset += 1), + syncTypeOptions: () => {}, + syncPlacementFields: () => {}, + renderOptionFields: () => {}, + syncFacilityForm: () => {}, + liveCommit: () => { + calls.live += 1; + const at = structures.findIndex((s) => s.structure_id === editingId); + if (commitOk && at >= 0) structures[at] = { ...structures[at] }; + }, + loadForm: (target) => calls.loaded.push(target?.type_id ?? null), + emit: () => (calls.emit += 1), + addAt: async (at, typeId) => (calls.addAt.push([at, typeId]), addOk), + selectPipe: (at) => calls.loaded.push(`pipe@${at}`), + interval: () => 20, + }; + bindStructuresEvents(ctx); + ctx.typeSelect.listeners.change(); + await new Promise((resolve) => setTimeout(resolve)); // 바꿈(지우고 새로)은 놓인 뒤에 옛 것을 지움 + return { ...calls, left: structures.map((s) => s.type_id), toasts: [...toasts] }; + }; + const store = window.sessionStorage; + const kindreset = () => JSON.parse(store.getItem("aislo:draft:kindreset:P") ?? "null"); + store.setItem( + "aislo:draft:culvertopt:P:1", + JSON.stringify({ "80.00": { a: 1 }, "300.00": { b: 2 } }), + ); + out.pipe_to_underdrain = { + ...(await run({ + pipes: [{ chainage_m: 80, facility: "pipe" }], + pipeAt: 80, + next: "cross_underdrain", + })), + kindreset: kindreset(), + culvertopt: JSON.parse(store.getItem("aislo:draft:culvertopt:P:1")), + }; + // 새로 놓인 맹암거(같은 측점) → 처음 종류(배관)로 되돌림 = 비울 자리 기록이 빠짐(318-4 규칙) + out.underdrain_back_to_pipe = { + ...(await run({ + structures: [ + { structure_id: "n1", type_id: "cross_underdrain", placement: "point", chainage_m: 80 }, + ], + editing: "n1", + next: "pipe", + })), + kindreset: kindreset(), + }; + out.open_ditch_to_box = { + ...(await run({ + structures: [ + { structure_id: "s1", type_id: "sign", placement: "point", chainage_m: 10 }, + { structure_id: "s9", type_id: "open_ditch", placement: "point", chainage_m: 120 }, + ], + editing: "s9", + next: "box_culvert", + })), + kindreset: kindreset(), + }; + out.pipe_to_box = await run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: "box_culvert", + }); + out.underdrain_to_open_ditch = await run({ + structures: [ + { structure_id: "s2", type_id: "cross_underdrain", placement: "point", chainage_m: 60 }, + ], + editing: "s2", + next: "open_ditch", + }); + out.to_blank = await run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: null, + }); + // 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리 + out.point_to_interval = await run({ + structures: [{ structure_id: "m1", type_id: "mirror", placement: "point", chainage_m: 140 }], + editing: "m1", + next: "guardrail", + }); + out.interval_to_point = await run({ + structures: [ + { + structure_id: "g1", + type_id: "guardrail", + placement: "interval", + start_m: 100, + chainage_m: 105, + end_m: 120, + }, + ], + editing: "g1", + next: "mirror", + }); + out.interval_same = await run({ + structures: [ + { + structure_id: "r1", + type_id: "refuge", + placement: "interval", + start_m: 100, + chainage_m: 105, + end_m: 120, + }, + ], + editing: "r1", + next: "work_yard", + }); + // 바꿈(지우고 새로)인데 새 종류를 바로 못 놓음(빈 칸) = 옛 것 · 옛 폼 그대로 + 알림(323-6 고침 · 반사경 → 가드레일) + out.replace_blocked = await run({ + structures: [{ structure_id: "m2", type_id: "mirror", placement: "point", chainage_m: 300 }], + editing: "m2", + next: "guardrail", + addOk: false, + }); + out.replace_blocked_pipe = await run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: "cross_underdrain", + addOk: false, + }); + // 막힘 = 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰) + out.blocked = await run({ + structures: [{ structure_id: "b1", type_id: "mirror", placement: "point", chainage_m: 30 }], + editing: "b1", + next: "curb", + commitOk: false, + }); +})(); + +// ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ────── +const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedStructures: [] }; +(async () => { + await part1; + const load = makeLoader( + [ + B06("B06_Section_Section_Drafts"), + path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), + path.join(ROOT, "common_util", "common_util_structure_station.ts"), + // 그림 자료 = 형상 얹기 한 벌(323-4) + ...["attach", "shape_keys", "walls", "pavement", "underdrain"].map((name) => + path.join(ROOT, "common_util", `common_util_structure_${name}.ts`), + ), + ], + { + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }), + }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: state.savedStructures }), + fetchStructureTypes: async () => TYPES, + readPendingStructures: () => state.draftStructures, + }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => state.draftPipes }, + "./B06_Section_Api_Fetch": { + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ chainage_m: at, kind: "irregular", design: { fresh: true } })), + }, + }, + ); + const drafts = load(B06("B06_Section_Section_Drafts")); + const { forgetDraftStations } = drafts; + // 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례) + const withDraftStations = async (detail, projectId, routeId) => { + await drafts.withDraftStations(detail, projectId, routeId); + await drafts.withDraftShapes(detail, projectId); + }; + const D800 = { pipe_kind: "파형강관", pipe_diameter_mm: "800" }; + const detailWith = (spec) => ({ + cross_sections: [ + { chainage_m: 20, kind: "regular", design: {} }, + { chainage_m: 40, kind: "irregular", grid: true, design: { keep: 1 }, ...spec }, + { chainage_m: 60, kind: "regular", design: {} }, + ], + }); + const at40 = (detail) => { + const section = detail.cross_sections.find((s) => Math.abs(s.chainage_m - 40) < 0.01); + return { + count: detail.cross_sections.length, + keys: Object.keys(section).filter((k) => + ["culvert", "box", "ford", "ford_pavement"].includes(k), + ), + structure: section.structure ?? null, + preview: Boolean(section.preview), + fresh: Boolean(section.design?.fresh), + keep: section.design?.keep ?? null, + }; + }; + const grid = {}; + // 저장분 = 격자 40 위 배관 + state.savedPipes = [{ chainage_m: 40, facility: "pipe", options: D800 }]; + state.savedStructures = []; + let detail = detailWith({ structure: "파형강관 D800", culvert: { chainage_m: 40 } }); + state.draftPipes = []; + state.draftStructures = [ + { + structure_id: "n1", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 40, + options: {}, + }, + ]; + await withDraftStations(detail, "P", 1); + grid.pipe_to_underdrain = at40(detail); + state.draftStructures = []; + await withDraftStations(detail, "P", 1); + grid.pipe_deleted = at40(detail); + state.draftPipes = [{ chainage_m: 40, facility: "pipe", options: D800 }]; + state.draftStructures = null; + await withDraftStations(detail, "P", 1); + grid.pipe_back = at40(detail); + state.draftPipes = [{ chainage_m: 40, facility: "box_culvert" }]; + await withDraftStations(detail, "P", 1); + grid.pipe_to_box = at40(detail); + // 저장분 = 격자 40 위 맹암거 → 배관 + forgetDraftStations("P"); + state.savedPipes = []; + state.savedStructures = [ + { + structure_id: "s1", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 40, + options: {}, + }, + ]; + detail = detailWith({ structure: "맹암거", underdrain: { chainage_m: 40 } }); + state.draftPipes = [{ chainage_m: 40, facility: "pipe", options: D800 }]; + state.draftStructures = []; + await withDraftStations(detail, "P", 1); + grid.underdrain_to_pipe = at40(detail); + forgetDraftStations("P"); + out.grid = grid; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); +}); diff --git a/resources/tester/helper_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs new file mode 100644 index 000000000..ec272f756 --- /dev/null +++ b/resources/tester/helper_322_0_golden.cjs @@ -0,0 +1,347 @@ +/* PLAN 322-0 전수 표 시험 헬퍼 — 42종 × 견본(구조물 · 관 지점 · 빈칸)마다 지금 화면 함수 결과를 표준출력 JSON 한 벌로. + * 측점(`structureStationMarks` · `pipeStationMarks`) · 카드 이름 · 형상 열쇠(`Section_Store` 초안 얹기 → 미리 뜬 횡단 · + * 벽 · 노면 · 맹암거) · 3D 몸체(`buildCorridorStructures`) · 알약 띠(`buildStructureLane`) · 고르기 목록(B05 · B06 `pickGroups`). + * 입력 = argv[2] JSON(서버 꼴 types · 정의 · 견본 · 격자 · 맨 횡단) — 파이썬 짝이 만듦. 짝 = `test_322_0_golden.py`. + * 진짜 TS 를 그 자리에서 트랜스파일해 Node 로 돌림(서버 부르는 곳 · 무거운 화면 모듈만 흉내). + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const clone = (v) => JSON.parse(JSON.stringify(v)); + +/* ── 브라우저 흉내 ─────────────────────────────────────────────────────── */ +class FakeStorage { + constructor() { + this.map = new Map(); + } + get length() { + return this.map.size; + } + key(i) { + return [...this.map.keys()][i] ?? null; + } + getItem(k) { + return this.map.has(k) ? this.map.get(k) : null; + } + setItem(k, v) { + this.map.set(k, String(v)); + } + removeItem(k) { + this.map.delete(k); + } + clear() { + this.map.clear(); + } +} +class FakeElement { + constructor(tag) { + this.tagName = tag; + this.className = ""; + this.style = {}; + this.dataset = {}; + this.children = []; + this.textContent = ""; + this.classList = { toggle: () => {}, add: () => {}, remove: () => {}, contains: () => false }; + } + append(...nodes) { + this.children.push(...nodes); + } + prepend(...nodes) { + this.children.unshift(...nodes); + } + appendChild(node) { + this.children.push(node); + return node; + } + addEventListener() {} + setAttribute() {} +} +globalThis.window = { + sessionStorage: new FakeStorage(), + localStorage: new FakeStorage(), + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => true, + requestAnimationFrame: (fn) => fn(), + setTimeout, + clearTimeout, + location: { origin: "http://localhost", pathname: "/" }, +}; +globalThis.sessionStorage = window.sessionStorage; +globalThis.localStorage = window.localStorage; +globalThis.requestAnimationFrame = (fn) => fn(); +const docListeners = new Map(); +globalThis.document = { + activeElement: null, + hidden: false, + visibilityState: "visible", + addEventListener: (type, fn) => docListeners.set(type, fn), + removeEventListener: (type, fn) => docListeners.get(type) === fn && docListeners.delete(type), + createElement: (tag) => new FakeElement(tag), +}; +/** 서버 흉내 — 정의 판(`server.version`) · 종류 받은 횟수(`server.typeCalls`). */ +const server = { version: input.definition.판, typeCalls: 0 }; +globalThis.fetch = async (url) => { + const at = String(url); + if (at.includes("/structure-types")) server.typeCalls += 1; + const body = at.includes("structure-definition/version") + ? { 판: server.version } + : at.includes("structure-definition") + ? { ...clone(input.definition), 판: server.version } + : at.includes("/route/structures") + ? { status: "success", project_id: PROJECT, revision: 0, structures: [] } + : { status: "success", schema_version: 1, types: clone(input.types) }; + return { ok: true, status: 200, json: async () => body }; +}; + +/* ── TS 싣기 — 흉내 파일만 갈음 ──────────────────────────────────────────── */ +const file = (...parts) => path.resolve(ROOT, ...parts); +const B05 = (name) => file("B05_Profile", `${name}.ts`); +const B06 = (name) => file("B06_Section", `${name}.ts`); +const PROJECT = "P322"; +let pendingPipes = null; +const STUBS = new Map([ + [ + B06("B06_Section_Api_Fetch"), + { + fetchSectionDetail: async () => currentDetail(), + saveSections: async () => ({}), + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ ...plainAt(at), kind: "irregular" })), + }, + ], + [B06("B06_Section_Cross_Refresh"), { USER_TOUCHED_KEYS: [] }], + [ + B06("B06_Section_Api_Culvert_Options"), + { kindChangedChainages: () => [], STRUCTURE_ADJUST_KEYS: [] }, + ], + [ + B06("B06_Section_Structure_Layouts"), + { applyStructureAreaRows: () => {}, structureAreaRows: () => [] }, + ], + [ + B05("B05_Profile_Api_Pipes_Draft"), + { readPendingPipes: () => pendingPipes, writePendingPipes: () => {} }, + ], + [ + file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"), + { fetchDetailPipePoints: async () => ({ pipe_points: [] }), noteSnapshotFailed: () => {} }, + ], + [file("A00_Common", "touch_bridge.ts"), { pickPx: () => 6 }], + [file("ui_template", "ui_template_toast.ts"), { showToast: () => {} }], + [file("ui_template", "ui_template_elements.ts"), { showToast: () => {} }], +]); +const resolveFile = (base) => + [base, `${base}.ts`, base.replace(/\.js$/, ".ts"), path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +const cache = new Map(); +function loadTs(target) { + const full = path.resolve(target); + if (STUBS.has(full)) return STUBS.get(full); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + if (spec === "three") return require(path.join(ROOT, "config", "node_modules", "three")); + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (spec.startsWith(prefix)) + return loadTs(resolveFile(path.join(ROOT, dir, spec.slice(prefix.length)))); + if (spec.startsWith(".")) return loadTs(resolveFile(path.join(path.dirname(full), spec))); + return require(spec); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +/* ── 견본 횡단 ───────────────────────────────────────────────────────────── */ +const plainAt = (at) => ({ ...clone(input.plain_section), chainage_m: at, kind: "regular" }); +const currentDetail = () => ({ + longitudinal: { stations: input.grid.map((at) => ({ chainage_m: at, kind: "regular" })) }, + cross_sections: input.grid.map(plainAt), +}); +const frameAtOf = (sections) => (atM) => { + const s = sections.reduce((b, x) => + Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b, + ); + const [lx, ly] = s.frame.left_xy; + const along = atM - s.chainage_m; + return { + cx: s.center_x - ly * along, + cy: s.center_y + lx * along, + leftX: lx, + leftY: ly, + designZ: s.design?.design_elevation_m ?? null, + }; +}; + +/** 횡단 하나에 얹힌 구조물 그림 자료 — 열쇠 + 고르는 데 쓰는 값만(파이썬 짝 `_shape_of` 와 같은 꼴). */ +const SHAPE_KEYS = [ + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", +]; +function shapeOf(section) { + const out = {}; + for (const key of SHAPE_KEYS) { + const spec = section[key]; + if (!spec) continue; + if (key === "culvert") out[key] = { hidden_pipe: !!spec.hidden_pipe, body: !!spec.body }; + else if (key === "revetment") + out[key] = { type_id: spec.type_id ?? null, form: spec.form ?? null }; + else if (key === "pavement") out[key] = spec.map((p) => p.type_id).sort(); + else out[key] = true; + } + return out; +} +const at2 = (n) => Number(n).toFixed(2); + +const { structureStationMarks, pipeStationMarks } = loadTs( + file("common_util", "common_util_structure_station.ts"), +); +const { + dropStaleStructureTypes, + fetchStructureTypes, + onStaleStructureTypes, + pipesToStructureMarks, + staleDefinitionNotice, + writePendingStructures, +} = loadTs(B05("B05_Profile_Api_Structures")); +const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store")); +const { forgetDraftStations } = loadTs(B06("B06_Section_Section_Drafts")); +const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures")); +const { computeRevetmentLayout } = loadTs(B06("B06_Section_UI_Cross_Revetment")); +const { buildStructureLane } = loadTs(B05("B05_Profile_UI_Structures_Marks")); +const { pickGroups } = loadTs(B05("B05_Profile_UI_Structures_Panel_Types")); +const { structureDisplayName } = loadTs(B06("B06_Section_UI_Section_Common")); +const { listPick } = loadTs(file("common_util", "common_util_structure_catalog.ts")); + +async function main() { + const types = await fetchStructureTypes(true); + const byId = new Map(types.map((type) => [type.type_id, type])); + const out = { samples: {}, pick: {} }; + + // 고르기 목록 — 페이지가 넘기는 거르기 그대로(B05 = 켜진 것 · B06 = 전부) + const pagePicks = { b05: listPick("b05"), b06: listPick("b06") }; // 페이지 거르기 = 표 목록(322-1) + for (const [page, pick] of Object.entries(pagePicks)) { + const seen = {}; + for (const group of pickGroups(types, pick)) + for (const member of group.types) + seen[member.type.type_id] = group.hidden || member.hidden ? "hidden" : "shown"; + for (const type of types) + (out.pick[type.type_id] ??= {})[page] = seen[type.type_id] ?? "absent"; + } + + let route = 0; + for (const sample of input.samples) { + route += 1; + const type = byId.get(sample.type_id); + const row = {}; + // 측점 + const marks = sample.pipe + ? pipeStationMarks(sample.pipe) + : structureStationMarks(sample.structure, type); + row.station = marks.map((m) => `${m.role}@${at2(m.chainage_m)}`); + + // 초안 얹기(Section_Store) → 카드 이름 · 형상 열쇠 + window.sessionStorage.clear(); + forgetDraftStations(PROJECT); + pendingPipes = sample.pipe ? [clone(sample.pipe)] : null; + writePendingStructures(PROJECT, sample.structure ? [clone(sample.structure)] : null); + const detail = await loadSectionDetail(PROJECT, route, { force: true }); + const touched = detail.cross_sections.filter( + (section) => section.structure || Object.keys(shapeOf(section)).length, + ); + row.cards = Object.fromEntries( + touched.map((section) => [ + at2(section.chainage_m), + { + title: section.structure ?? null, + display: section.structure ? structureDisplayName(section.structure) : null, + shape: shapeOf(section), + }, + ]), + ); + // 벽 물빼기(옹벽만 · 형상칸.weep) + row.weep = touched + .filter((section) => section.revetment) + .some((section) => + (computeRevetmentLayout(section, undefined)?.walls ?? []).some((wall) => wall.retaining), + ); + // 3D 몸체 + const solids = touched.length ? buildCorridorStructures(touched, frameAtOf(touched)) : []; + row.model3d = [...new Set(solids.map((s) => `${s.kind}:${s.key ?? ""}`))].sort(); + + // 알약 띠 + const lane = buildStructureLane({ + structures: sample.pipe + ? pipesToStructureMarks([sample.pipe]) + : [{ ...sample.structure, structure_id: "s1" }], + types, + x: (m) => m, + chainageAt: (px) => px, + maxChainageM: 400, + widthPx: 400, + axisWidthPx: 40, + stationIntervalM: 20, + selectedId: null, + onSelect: () => {}, + }); + row.band = lane.children.filter((el) => el.className.startsWith("b05-structure__band")).length; + out.samples[`${sample.type_id}/${sample.case}`] = row; + } + // 판 확인(322-0 ③) — 같은 판 = 캐시 그대로 · 바뀐 판 = 버리고 다시 받음 · 탭이 다시 보이면 알림 + await fetchStructureTypes(true); + server.typeCalls = 0; + const same = await dropStaleStructureTypes(); + await fetchStructureTypes(true); + const sameCalls = server.typeCalls; + let reloads = 0; + const off = onStaleStructureTypes(() => (reloads += 1)); + server.version += 1; + docListeners.get("visibilitychange")?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await fetchStructureTypes(true); + const staleCalls = server.typeCalls - sameCalls; + off(); + // 판 알림(322-2) — 프로젝트 정본이 옛 판으로 적혔으면 한 번만 · 같은 판 · 판 없음은 안 알림 + const old = server.version - 1; + out.notice = [ + staleDefinitionNotice(PROJECT, old), + staleDefinitionNotice(PROJECT, old), + staleDefinitionNotice(PROJECT, server.version), + staleDefinitionNotice(PROJECT, null), + ]; + out.version = { + same, + sameCalls, + reloads, + staleCalls, + removed: !docListeners.has("visibilitychange"), + }; + process.stdout.write(JSON.stringify(out)); +} + +main().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_322_1_catalog.cjs b/resources/tester/helper_322_1_catalog.cjs new file mode 100644 index 000000000..f1724f663 --- /dev/null +++ b/resources/tester/helper_322_1_catalog.cjs @@ -0,0 +1,43 @@ +/* PLAN 322-1 구조물 표 거울 시험 헬퍼 — 진짜 `common_util_structure_catalog.ts` 를 그 자리에서 트랜스파일해 + * 견본 정의마다 `resolveCatalog` 한 벌 · 실제 정의로 `cardTitle` · `listPick` 을 표준출력 JSON 으로. + * 입력 = argv[2] JSON `{cases: {이름: 정의|null}, definition, titles: [[typeId, options, defaults]]}`. + * 짝 = `test_322_1_catalog_mirror.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function loadTs(full) { + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + const need = (spec) => loadTs(path.join(path.dirname(full), spec)); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = loadTs(path.join(ROOT, "common_util", "common_util_structure_catalog.ts")); +const rows = (map) => Object.fromEntries([...map].map(([id, row]) => [id, row])); +const out = { cases: {} }; +for (const [name, definition] of Object.entries(input.cases)) + out.cases[name] = rows(catalog.resolveCatalog(definition)); +catalog.useCatalogDefinition(input.definition); +out.titles = input.titles.map(([id, options, defaults]) => + catalog.cardTitle(id, options, defaults), +); +out.pick = Object.fromEntries( + input.definition.types.map((t) => [ + t.type_id, + [catalog.listPick("b05")(t), catalog.listPick("b06")(t)], + ]), +); +out.kinds = [...catalog.pipeTitleKinds()].sort(); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_322_2_check.cjs b/resources/tester/helper_322_2_check.cjs new file mode 100644 index 000000000..faf8f8b8a --- /dev/null +++ b/resources/tester/helper_322_2_check.cjs @@ -0,0 +1,127 @@ +/* PLAN 322-2 구조물 표 검사 · 편집 헬퍼 — 진짜 `M02_SO_Def_Check.ts` · `M02_SO_Def_Reflect.ts` · + * `design_structure_spec.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = argv[2] JSON `{cases: {이름: [정의, 앞 판|null]}, definition}`. 짝 = `test_322_2_check_mirror.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + const base = path.join(path.dirname(full), spec); + const hit = [base, `${base}.ts`, base.replace(/\.js$/, ".ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const SO = (name) => path.join(ROOT, "M02_MasterTemplete", "StructOptions", `${name}.ts`); +const check = load(SO("M02_SO_Def_Check")); +const reflect = load(SO("M02_SO_Def_Reflect")); +const spec = load(path.join(ROOT, "A00_Common", "design", "design_structure_spec.ts")); +const { STRUCTURE_SPECS } = load( + path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"), +); +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const out = { cases: {} }; + +for (const [name, [doc, before]] of Object.entries(input.cases)) + out.cases[name] = check.catalogProblems(doc, before); + +// 반영 손질 — 목록 + enabled 짝 · 처음 값이면 지움 · 형상칸 · 보임 · 부품 쓰는 종류 +const def = clone(input.definition); +const enabled = (id) => def.types.find((t) => t.type_id === id).enabled; +const own = (id) => clone(reflect.reflectOf(def)[id] ?? null); +reflect.setReflect(def, "wildlife_path", "목록", "both"); +reflect.setReflect(def, "retaining_wall", "목록", "b06"); +reflect.setReflect(def, "open_ditch", "횡단도", "none"); +reflect.setReflect(def, "cross_drain_exposed", "횡단도", "anchor"); +const setBefore = reflect.isSet(def, "cross_drain_exposed", "횡단도"); +reflect.setShapeOpt(def, "soil_guard", "weep", true); +const weepOn = own("soil_guard"); +reflect.setShapeOpt(def, "soil_guard", "weep", false); +reflect.setShown(def, "pipe", ["inlet_type", "없는키"], false); +out.reflect = { + wildlife: [own("wildlife_path"), enabled("wildlife_path")], + wall: [own("retaining_wall"), enabled("retaining_wall")], + openDitch: own("open_ditch"), + exposed: [own("cross_drain_exposed"), setBefore], + weepOn, + weepOff: own("soil_guard"), + shown: clone(def.표시.pipe), + readPipe: [...reflect.readKeys(def, "pipe")].sort(), + wallUsers: reflect.usersOf(def, "형상", "wall").map((row) => row.typeId), +}; +reflect.resetRow(def, "cross_drain_exposed"); +reflect.resetRow(def, "retaining_wall"); +out.reflect.reset = [own("cross_drain_exposed"), own("retaining_wall"), enabled("retaining_wall")]; +// 저장 전 목록 + enabled 함께 — 목록은 그대로 · enabled = both 일 때만 참 +const plain = clone(input.definition); +const lists = (d) => [...reflect.rowsOf(d).values()].map((row) => `${row.typeId}:${row.list}`); +const listBefore = lists(plain); +reflect.syncEnabled(plain); +out.sync = { + same: JSON.stringify(lists(plain)) === JSON.stringify(listBefore), + enabled: plain.types.filter((t) => t.enabled).map((t) => t.type_id), +}; +reflect.setPartOn(plain, "형상.wall", false); +out.partOff = [clone(plain.부품), reflect.rowsOf(plain).get("retaining_wall").shape]; +reflect.setPartOn(plain, "형상.wall", true); +out.partOn = clone(plain.부품); + +// 바꿨다 되돌리면 저장할 것 없음(322-2 다듬기) — 목록 · 횡단도 · 형상칸 · 보임 · 부품 +const base = clone(input.definition); +const work = clone(input.definition); +const steps = []; +reflect.setReflect(work, "masonry_wet", "목록", "both"); +steps.push(reflect.differs(work, base)); +reflect.setReflect(work, "masonry_wet", "목록", "hidden"); +reflect.setReflect(work, "open_ditch", "횡단도", "anchor"); +reflect.setReflect(work, "open_ditch", "횡단도", "none"); +reflect.setShapeOpt(work, "soil_guard", "form", "콘크리트"); +reflect.setShapeOpt(work, "soil_guard", "form", "통나무·목재틀"); +reflect.setShown(work, "pipe", ["wing_wall_type"], false); +reflect.setShown(work, "pipe", ["wing_wall_type"], true); +reflect.setPartOn(work, "3D.chute", false); +reflect.setPartOn(work, "3D.chute", true); +steps.push(reflect.differs(work, base)); +out.revert = steps; + +// 명세 폼 숨김 — 저장 · 갈래 키가 모두 숨김인 칸 · 키 있는 칸이 모두 숨긴 상자 +out.spec = {}; +for (const [tid, sp] of Object.entries(STRUCTURE_SPECS)) { + const keys = sp.containers.flatMap((c) => c.items.flatMap((i) => spec.specItemKeys(i))); + const hidden = new Set(keys.slice(0, 1)); + const all = new Set(keys); + out.spec[tid] = { + first: keys[0] ?? null, + hiddenItems: sp.containers.flatMap((c) => + c.items.filter((i) => spec.specItemHidden(i, hidden)).map((i) => `${c.id}.${i.id}`), + ), + allBoxes: sp.containers.filter((c) => spec.specContainerHidden(c, all)).map((c) => c.id), + keyedBoxes: sp.containers + .filter((c) => c.items.some((i) => spec.specItemKeys(i).length)) + .map((c) => c.id), + noneHidden: sp.containers.filter((c) => spec.specContainerHidden(c, new Set())).length, + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs new file mode 100644 index 000000000..0dddc8d54 --- /dev/null +++ b/resources/tester/helper_322_3_flow.cjs @@ -0,0 +1,241 @@ +/* PLAN 322-3 카드 목록 흐름 헬퍼 — 진짜 `B06_Section_Section_Drafts.ts`(withDraftStations) + 측점 계획 · + * 구조물 표 · 관 세트 산식을 그 자리에서 트랜스파일해 Node 로 돌림(서버 · 세션만 흉내) · 결과 = 표준출력 JSON. + * 흐름 일곱 = 놓기 · 일반 측점에 놓기(219) · 옮기기 · 같은 군 바꿈 · 정본 넘는 바꿈 · 지우기 · 저장 뒤 다시 열기. + * 짝 = `test_322_3_flow.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const DEF = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t })); + +const state = { savedPipes: [], savedStructures: [], draftPipes: null, draftStructures: null }; +const memory = new Map(); +const store = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + key: (i) => [...memory.keys()][i] ?? null, + get length() { + return memory.size; + }, +}; +globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} }; +globalThis.sessionStorage = store; +globalThis.localStorage = store; +const file = (...parts) => path.resolve(ROOT, ...parts); +const STUBS = new Map([ + [ + file("B04_PreProcess", "B04_PreProcess_Api_Fetch.ts"), + { fetchDetailPipePoints: async () => ({ pipe_points: state.savedPipes }) }, + ], + [ + file("B05_Profile", "B05_Profile_Api_Structures.ts"), + { + fetchStructures: async () => ({ structures: state.savedStructures }), + fetchStructureTypes: async () => TYPES, + readPendingStructures: () => state.draftStructures, + }, + ], + [ + file("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts"), + { readPendingPipes: () => state.draftPipes }, + ], + [ + file("B06_Section", "B06_Section_Api_Fetch.ts"), + { + previewStructureSections: async (_p, chainages) => + chainages.map((at) => ({ chainage_m: at, kind: "irregular", station_id: `p${at}` })), + }, + ], +]); +const cache = new Map(); +function load(target) { + const full = path.resolve(target); + if (STUBS.has(full)) return STUBS.get(full); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + if (spec.startsWith("@ui/")) return { showToast: () => {} }; + const alias = spec.startsWith("@util/") ? path.join(ROOT, "common_util", spec.slice(6)) : null; + const base = alias ?? path.join(path.dirname(full), spec); + const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find( + (p) => + (STUBS.has(path.resolve(p)) || fs.existsSync(p)) && + !fs.statSync(p, { throwIfNoEntry: false })?.isDirectory(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +load(file("common_util", "common_util_structure_catalog.ts")).useCatalogDefinition(DEF); +const drafts = load(file("B06_Section", "B06_Section_Section_Drafts.ts")); + +const grid = () => + Array.from({ length: 11 }, (_v, i) => ({ + chainage_m: i * 20, + kind: "regular", + station_id: `g${i * 20}`, + })); +const irregular = (at, structure, extra = {}) => ({ + chainage_m: at, + kind: "irregular", + station_id: `i${at}`, + structure, + ...extra, +}); +const wall = (type_id, id = "w1") => ({ + structure_id: id, + type_id, + placement: "interval", + start_m: 93, + chainage_m: 101, + end_m: 113, + options: { height_m: 3 }, +}); +const summary = (detail) => + detail.cross_sections.map((s) => [ + s.chainage_m, + s.structure ?? null, + !!s.preview, + ["culvert", "ford", "box"].filter((k) => s[k]), + ]); + +let route = 0; +async function run(setup, detail) { + Object.assign(state, { + savedPipes: [], + savedStructures: [], + draftPipes: null, + draftStructures: null, + ...setup, + }); + drafts.forgetDraftStations("P"); + route += 1; + await drafts.withDraftStations(detail, "P", route); + await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4) + return summary(detail); +} + +(async () => { + const out = {}; + out.place = await run({ draftStructures: [wall("retaining_wall")] }, { cross_sections: grid() }); + out.gridPlace = await run( + { + draftStructures: [ + { + structure_id: "u1", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 100, + options: {}, + }, + ], + draftPipes: [{ chainage_m: 120, options: { pipe_diameter_mm: "800" } }], + }, + { cross_sections: grid() }, + ); + const pipeSet = { culvert: { type: "pipe" } }; + out.move = await run( + { + savedPipes: [{ chainage_m: 61, options: { pipe_diameter_mm: "1000" } }], + draftPipes: [{ chainage_m: 75, options: { pipe_diameter_mm: "1000" } }], + }, + { cross_sections: [...grid(), irregular(61, "파형강관 D1000", pipeSet)] }, + ); + const walls = () => [irregular(93, "옹벽"), irregular(101, "옹벽"), irregular(113, "옹벽")]; + out.sameGroup = await run( + { savedStructures: [wall("retaining_wall")], draftStructures: [wall("soil_guard")] }, + { cross_sections: [...grid(), ...walls()] }, + ); + out.crossOwner = await run( + { + savedPipes: [{ chainage_m: 101, options: { pipe_diameter_mm: "1000" } }], + draftPipes: [], + draftStructures: [ + { + structure_id: "u2", + type_id: "cross_underdrain", + placement: "point", + chainage_m: 101, + options: {}, + }, + ], + }, + { cross_sections: [...grid(), irregular(101, "파형강관 D1000", pipeSet)] }, + ); + const kept = { cross_sections: [...grid(), ...walls()] }; + out.remove = await run({ savedStructures: [wall("retaining_wall")], draftStructures: [] }, kept); + // 저장 뒤 다시 열기 — 초안 없음 = 서버 목록 그대로(빼 둔 옛 자리 돌아옴) + out.reopen = await run({ savedStructures: [wall("retaining_wall")] }, kept); + // 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · id = 관 세션 id 그대로(323-5) · 다른 고름은 그대로 + const sel = load(file("A00_Common", "design", "design_selection.ts")); + const pipeIds = load(file("A00_Common", "design", "design_pipe_ids.ts")); + sel.selectDesign("P", { chainageM: 61, structureId: pipeIds.pipeIdAt(61, "P") }); + pipeIds.followMovedPipe("P", 61, 75); + const moved = sel.readSelection("P"); + sel.selectDesign("P", { chainageM: 101, structureId: "w1" }); + pipeIds.followMovedPipe("P", 101, 120); + out.follow = [moved, sel.readSelection("P")]; + // 옛 소수 관(검증_sub1 견본) — B06 을 바로 열어도 B05 와 같은 정수 m 초안(`snapPlan` · 요구 167) + const snap = load(file("B05_Profile", "B05_Profile_UI_Structures_Snap.ts")); + const DEC = [ + 85.05, 173.09, 258.12, 352.14, 440.24, 533.98, 619.71, 720.44, 804.18, 899.91, 982.64, + ]; + const ford = new Set([173.09, 352.14]); + const decPipes = DEC.map((at) => ({ + chainage_m: at, + facility: ford.has(at) ? "ford_bridge" : "pipe", + options: { pipe_diameter_mm: "800" }, + })); + const plan = snap.snapPlan([], decPipes); + out.snapTo = [plan.pipes.map((move) => move.to), plan.skipped]; + const snapped = decPipes.map((pipe) => ({ + ...pipe, + chainage_m: plan.pipes.find((move) => move.from === pipe.chainage_m)?.to ?? pipe.chainage_m, + })); + const gridTo = (end) => + Array.from({ length: end / 20 + 1 }, (_v, i) => ({ + chainage_m: i * 20, + kind: "regular", + station_id: `g${i * 20}`, + })); + const titleAt = (at) => (ford.has(at) ? "세월교" : "파형강관 D800"); + const own = [85, 173, 258, 352, 534, 804, 983]; + const onGrid = [440, 620, 720, 900]; + // 서버가 정수 자리로 심은 저장분(격자 자리는 구조물 측점 하나 · grid) — 새 미리 뜬 카드 0 + const savedInt = [ + ...gridTo(1000).filter((s) => !onGrid.includes(s.chainage_m)), + ...onGrid.map((at) => irregular(at, "파형강관 D800", { grid: true })), + ...own.map((at) => irregular(at, at === 173 || at === 352 ? "세월교" : "파형강관 D800")), + ]; + out.snapSavedInt = await run( + { savedPipes: decPipes, draftPipes: snapped, draftStructures: [] }, + { cross_sections: savedInt }, + ); + // 소수 자리로 심긴 저장분(검증_sub1 지금) = B05 를 먼저 연 것과 같음 — 소수 카드는 빼 두고 정수 자리 미리 뜸 + const savedDec = [...gridTo(1000), ...DEC.map((at) => irregular(at, titleAt(at)))]; + out.snapSavedDec = await run( + { savedPipes: decPipes, draftPipes: snapped }, + { cross_sections: savedDec }, + ); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_322_3_plan.cjs b/resources/tester/helper_322_3_plan.cjs new file mode 100644 index 000000000..5c10edd8c --- /dev/null +++ b/resources/tester/helper_322_3_plan.cjs @@ -0,0 +1,43 @@ +/* PLAN 322-3 측점 계획 한 벌 거울 헬퍼 — 진짜 `common_util_structure_station.ts`(planStations) · + * `common_util_structure_catalog.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON. + * 입력 = argv[2] JSON `{definition, types, cases: {이름: {pipes, structures}}}`. 짝 = `test_322_3_plan_mirror.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + const base = path.join(path.dirname(full), spec); + const hit = [base, `${base}.ts`, base.replace(/\.js$/, ".ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catalog.ts")); +const station = load(path.join(ROOT, "common_util", "common_util_structure_station.ts")); +catalog.useCatalogDefinition({ types: input.types, ...input.definition }); +const out = {}; +for (const [name, { pipes, structures }] of Object.entries(input.cases)) + out[name] = station.planStations(pipes, structures, input.types); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_322_catalog_module.cjs b/resources/tester/helper_322_catalog_module.cjs new file mode 100644 index 000000000..f06833065 --- /dev/null +++ b/resources/tester/helper_322_catalog_module.cjs @@ -0,0 +1,23 @@ +/* PLAN 322-1 — 옛 도움 파일들이 함께 쓰는 구조물 표(catalog) 모듈 한 벌. + * 진짜 `common_util_structure_catalog.ts` + 부품 상자 JSON · 실제 정의(마스터 읽기만)로 세운 표(화면이 종류 + * 레지스트리를 받은 뒤와 같음). 각 도움 파일 로더가 `…common_util_structure_catalog` 요청을 이것으로 갈음함. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const read = (...parts) => JSON.parse(fs.readFileSync(path.join(ROOT, ...parts), "utf8")); + +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "common_util", "common_util_structure_catalog.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => + read("common_util", "common_util_structure_parts.json"), +); +box.exports.useCatalogDefinition(read("resources", "master_template", "구조물정의.json")); + +/** 로더 요청이 구조물 표 모듈인가. */ +box.exports.isCatalogRequest = (request) => /common_util_structure_catalog(\.js)?$/.test(request); +module.exports = box.exports; diff --git a/resources/tester/helper_323_4_attach.cjs b/resources/tester/helper_323_4_attach.cjs new file mode 100644 index 000000000..c476f931d --- /dev/null +++ b/resources/tester/helper_323_4_attach.cjs @@ -0,0 +1,64 @@ +/* PLAN 323-4 형상 얹기 한 벌 헬퍼 — 진짜 `common_util_structure_attach.ts`(attachShapes) · 3D 지문 · + * 벽 한 번(`B05_Profile_UI_Corridor_Structures_Hash.ts`)을 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON. + * 입력 = argv[2] JSON `{definition, types, cases: {이름: {sections, pipes, structures}}}`. 짝 = `test_323_4_attach_mirror.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + const base = path.join(path.dirname(full), spec); + const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catalog.ts")); +const attach = load(path.join(ROOT, "common_util", "common_util_structure_attach.ts")); +const hash = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Hash.ts")); +catalog.useCatalogDefinition({ types: input.types, ...input.definition }); + +const out = { cases: {} }; +for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) { + attach.attachShapes(sections, pipes, structures, input.types); + out.cases[name] = sections; +} + +// 3D 벽 한 번 — 옹벽 93 ~ 113(기준 101)이 덮는 측점 넷 중 기준 측점만 · 스윕 = 시작 ~ 끝 +const wall = { structure_id: "w1", start_m: 93, anchor_m: 101, end_m: 113 }; +const covered = [93, 100, 101, 113].map((at) => ({ chainage_m: at, revetment: { ...wall } })); +out.sweep = covered.map((section) => hash.wallSweepAt(section, covered)); + +// 3D 지문 — 손 해시가 빠뜨린 칸(벽 기초 · 포장 · 맹암거)도 바뀌면 다름 · 구조물 없는 측점 = 빈 목록 +const base = { chainage_m: 0, revetment: { ...wall, foundation: null } }; +const fp = (section) => JSON.stringify(hash.structureHashParts(section)); +out.fingerprint = { + same: fp(base) === fp({ chainage_m: 0, revetment: { ...wall, foundation: null } }), + foundation: fp(base) !== fp({ ...base, revetment: { ...wall, foundation: "기초유" } }), + pavement: fp(base) !== fp({ ...base, pavement: [{ type_id: "pavement_concrete" }] }), + underdrain: fp(base) !== fp({ ...base, underdrain: { height_m: 0.5 } }), + adjust: fp(base) !== fp({ ...base, design: { revet_adjust: { own: { x: 1 } } } }), + plain: hash.structureHashParts({ chainage_m: 0 }).length === 0, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_323_5_pick.cjs b/resources/tester/helper_323_5_pick.cjs new file mode 100644 index 000000000..05c8f6afa --- /dev/null +++ b/resources/tester/helper_323_5_pick.cjs @@ -0,0 +1,94 @@ +/* PLAN 323-5 고름 한 칸 헬퍼 — 진짜 `design_pipe_ids.ts`(관 세션 id) · `design_selection.ts`(고름 · 관 옮김 따라가기) · + * `design_pick_match.ts`(고름 강조 한 규칙)를 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON. + * 짝 = `test_323_5_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const memory = new Map(); +const store = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + key: (i) => [...memory.keys()][i] ?? null, + get length() { + return memory.size; + }, +}; +globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} }; +globalThis.sessionStorage = store; +globalThis.localStorage = store; + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => load(path.join(path.dirname(full), `${spec}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const D = (name) => path.join(ROOT, "A00_Common", "design", `${name}.ts`); +const ids = load(D("design_pipe_ids")); +const sel = load(D("design_selection")); +const match = load(D("design_pick_match")); + +const out = {}; +// ① 관 세션 id — 자리마다 차례 · 같은 자리(0.51 안)면 같은 id · 프로젝트 없으면 옛 자리 이름 +ids.usePipeIds("P"); +const a = ids.pipeIdAt(85.05); +out.ids = [a, ids.pipeIdAt(85), ids.pipeIdAt(173), ids.pipeChainageOf(a)]; +ids.usePipeIds(null); +out.legacy = [ids.pipeIdAt(85), ids.pipeChainageOf("pipe-85.00"), ids.pipeChainageOf("w1")]; +ids.usePipeIds("P"); +// ② 옮겨도 id 그대로 — 고름도 자리만 새로(부재키 그대로) · 새 자리 id = 그 id +sel.selectDesign("P", { chainageM: 85.05, structureId: a }, { partKey: "inlet" }); +ids.followMovedPipe("P", 85.05, 90); +out.moved = [sel.readSelection("P"), ids.pipeIdAt(90), ids.pipeChainageOf(a)]; +// 다른 것을 골랐으면 고름 그대로 · 관 id 는 따라감 +const b = ids.pipeIdAt(173); +sel.selectDesign("P", { chainageM: 300, structureId: "w1" }); +ids.followMovedPipe("P", 173, 180); +out.other = [sel.readSelection("P"), ids.pipeIdAt(180) === b]; +// ③ 강조 한 규칙 +const L = match.pickLights; +out.lights = { + idOverPlace: L({ chainageM: 93, structureId: "w1" }, { chainageM: 101, structureId: "w1" }), + idDiffers: L({ chainageM: 101, structureId: "w2" }, { chainageM: 101, structureId: "w1" }), + placeNear: L({ chainageM: 85, structureId: null }, { chainageM: 85.4 }), + placeFar: L({ chainageM: 85, structureId: null }, { chainageM: 85.6 }), + partOnly: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85, part: "outlet" }), + partSame: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85, part: "inlet" }), + partNoTag: L({ chainageM: 85, partKey: "inlet" }, { chainageM: 85 }), + wholePick: L({ chainageM: 85 }, { chainageM: 85, part: "outlet" }), + none: L(null, { chainageM: 85 }), +}; +// ④ 카드 통째 형상 칠하기 — 부분 · 관 계열 · 포장층은 제 손잡이 몫 +class Shape { + constructor(attrs) { + this.attrs = attrs; + this.on = false; + this.classList = { toggle: (_c, v) => (this.on = v) }; + } + getAttribute(k) { + return this.attrs[k] ?? null; + } +} +const shapes = [ + new Shape({ "data-structure-id": "ud-1" }), + new Shape({ "data-structure-id": "w1", "data-part": "own" }), + new Shape({ "data-structure-id": a, "data-part": "concrete" }), + new Shape({ "data-structure-id": "pave-1", "data-pave-type": "pavement_concrete" }), +]; +const root = { querySelectorAll: () => shapes }; +match.paintPicked(root, { structureId: "ud-1" }); +const lit1 = shapes.map((s) => s.on); +match.paintPicked(root, { structureId: "w1" }); +out.paint = [lit1, shapes.map((s) => s.on)]; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_323_6_change.cjs b/resources/tester/helper_323_6_change.cjs new file mode 100644 index 000000000..df09a044a --- /dev/null +++ b/resources/tester/helper_323_6_change.cjs @@ -0,0 +1,82 @@ +/* PLAN 323-6 바꿈 한 길 헬퍼 — 진짜 `B05_Profile_Api_Structures.ts`(구조물 초안) · `design_store.ts`(바뀜 신호)를 + * 그 자리에서 트랜스파일해 Node 로 돌림(세션만 흉내) · 결과 = 표준출력 JSON. 짝 = `test_323_6_change.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const memory = new Map(); +const store = { + getItem: (k) => (memory.has(k) ? memory.get(k) : null), + setItem: (k, v) => memory.set(k, String(v)), + removeItem: (k) => memory.delete(k), + key: (i) => [...memory.keys()][i] ?? null, + get length() { + return memory.size; + }, +}; +globalThis.window = { sessionStorage: store, localStorage: store, addEventListener: () => {} }; +globalThis.sessionStorage = store; +globalThis.localStorage = store; + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + if (CATALOG.isCatalogRequest(spec)) return CATALOG; + if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + if (spec.startsWith("@config/")) return load(path.join(ROOT, "config", `${spec.slice(8)}.ts`)); + if (spec.startsWith(".")) return load(path.join(path.dirname(full), `${spec}.ts`)); + return require(spec); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts")); +const { useDesignStore } = load(path.join(ROOT, "A00_Common", "design", "design_store.ts")); +const heard = { structures: [], pipes: [] }; +const shop = useDesignStore("P", null); +for (const key of ["structures", "pipes"]) shop.subscribe(key, (value) => heard[key].push(value)); +const wall = { structure_id: "w1", type_id: "retaining_wall", chainage_m: 10 }; +api.writePendingStructures("P", [wall]); +api.writePendingStructures("P", null); +api.writePendingStructures("P", [wall]); +// 바뀜 신호 거르기 — 구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만(옵션만 = 폼 길이 그 카드) +const status = load(path.join(ROOT, "A00_Common", "design", "design_structure_status.ts")); +let fired = 0; +const off = status.watchDrafts("Q", () => (fired += 1)); +const shopQ = useDesignStore("Q", null); +shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 1 } }]); +shopQ.set("pipes", [{ chainage_m: 10, source: "user", options: { a: 2 } }]); +shopQ.set("pipes", [{ chainage_m: 12, source: "user", options: { a: 2 } }]); +shopQ.set("pipes", [{ chainage_m: 12, source: "user", facility: "box_culvert" }]); +shopQ.set("structures", []); +off(); +shopQ.set("structures", null); + +process.stdout.write( + JSON.stringify({ + watch: fired, + heard: heard.structures.map((v) => (v ? v.length : null)), + pipesHeard: heard.pipes.length, + read: api.readPendingStructures("P"), + key: memory.has("aislo:draft:structures:P"), + swaps: [ + "mirror>curb", + "mirror>guardrail", + "refuge>work_yard", + "pipe>box_culvert", + "pipe>revetment", + "cross_underdrain>pipe", + ].map((pair) => [pair, CATALOG.swapsInPlace(...pair.split(">"))]), + }), +); diff --git a/resources/tester/helper_323_7_new_type.cjs b/resources/tester/helper_323_7_new_type.cjs new file mode 100644 index 000000000..6ad52de98 --- /dev/null +++ b/resources/tester/helper_323_7_new_type.cjs @@ -0,0 +1,104 @@ +/* PLAN 323-7 종류 더하기 헬퍼 — 진짜 `M02_SO_Def_Add.ts`(본보기 복사) · 구조물 표 · 측점 계획 · 형상 얹기 · 벽 단 · + * 명세 고르기 · M02 검사를 그 자리에서 트랜스파일해 Node 로 돌림(마스터는 읽기만 · 사본에서 더함) · 결과 = JSON. + * 짝 = `test_323_7_new_type.py`(파이썬 검사 · 표 · 벽 제원이 같은 사본을 읽음). + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const need = (spec) => { + if (spec.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); + const base = path.join(path.dirname(full), spec); + const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const C = (name) => path.join(ROOT, "common_util", `${name}.ts`); +const M = (name) => path.join(ROOT, "M02_MasterTemplete", "StructOptions", `${name}.ts`); +const master = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const def = JSON.parse(JSON.stringify(master)); +const add = load(M("M02_SO_Def_Add")); +const catalog = load(C("common_util_structure_catalog")); +const station = load(C("common_util_structure_station")); +const attach = load(C("common_util_structure_attach")); +const walls = load(C("common_util_structure_walls")); +const pipeSpecs = load(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); +const check = load(M("M02_SO_Def_Check")); + +const out = {}; +out.templates = add.templates(def).map(([key]) => key); +out.pipeRefused = add.addFromTemplate(def, "pipe"); +const wall = add.addFromTemplate(def, "retaining_wall"); +const pave = add.addFromTemplate(def, "pavement_concrete"); +out.keys = [wall, pave]; +const types = def.types.map((t) => ({ managed_by: null, enabled: true, ...t })); +catalog.useCatalogDefinition({ ...def, types }); +const BRANCHES = [ + "list", + "station", + "title", + "shape", + "shapeOpts", + "model3d", + "placement", + "owner", +]; +const pick = (row) => Object.fromEntries(BRANCHES.map((k) => [k, row[k]])); +out.rows = { + wall: [pick(catalog.catalogRow("retaining_wall")), pick(catalog.catalogRow(wall))], + pave: [pick(catalog.catalogRow("pavement_concrete")), pick(catalog.catalogRow(pave))], +}; +out.base = [catalog.baseType(wall), catalog.baseType("retaining_wall")]; +out.names = [ + types.find((t) => t.type_id === wall).name, + types.find((t) => t.type_id === pave).name, +]; +const structure = { + structure_id: "n1", + type_id: wall, + placement: "interval", + start_m: 40, + chainage_m: 50, + end_m: 60, + options: { height_m: 2, tier_1_height: 1 }, +}; +out.plan = station.planStations([], [structure], types).map((s) => [s.chainage_m, s.title]); +const sections = [40, 50, 60].map((at) => ({ chainage_m: at })); +attach.attachShapes(sections, [], [structure], types); +out.attached = sections.map((s) => [s.revetment?.type_id ?? null, s.revetment?.name ?? null]); +out.tier = walls.tierExtras(wall, { tier_1_height: 1 }); +out.spec = + !!pipeSpecs.specFor(pave) && pipeSpecs.specFor(pave) === pipeSpecs.specFor("pavement_concrete"); +out.problems = check.catalogProblems(def, master); +// 본보기 바꿈 · 복사본의 복사 = 거절(만든 뒤 고정 · 원본만) +const bad = JSON.parse(JSON.stringify(def)); +bad.종류[wall].본보기 = "soil_guard"; +out.badBase = check.catalogProblems(bad, def).length > 0; +const chain = JSON.parse(JSON.stringify(def)); +chain.종류[pave].본보기 = wall; +out.chain = check.catalogProblems(chain, null).length > 0; +out.def = def; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_323_9_definition_stamp.cjs b/resources/tester/helper_323_9_definition_stamp.cjs new file mode 100644 index 000000000..2c6c36483 --- /dev/null +++ b/resources/tester/helper_323_9_definition_stamp.cjs @@ -0,0 +1,108 @@ +/* PLAN 323-9 ② — 정의 판 알림 되풀이. 진짜 `B05_Profile_Api_Structures.ts`(판 알림 · 정본 내보내기)를 + * Node 로 돌림. 서버는 가짜 한 벌 — 구조물 PUT 이 지금 정의 판을 도장(진짜 저장소와 같음). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const memory = () => { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +}; +globalThis.window = { + sessionStorage: memory(), + localStorage: memory(), + setTimeout: () => 0, + clearTimeout: () => undefined, +}; +globalThis.sessionStorage = globalThis.window.sessionStorage; + +// 가짜 서버 — 정의 판 21 · 프로젝트 정본은 판 20 으로 적힘 +const NOW = 21; +const server = { + revision: 3, + structures: [{ structure_id: "s1", type_id: "retaining_wall", chainage_m: 40 }], + definition_version: 20, +}; +const puts = []; +const reply = (body) => ({ ok: true, status: 200, json: async () => body }); +globalThis.fetch = async (url, init = {}) => { + const at = url.replace(/^.*?(?=\/(m02|projects))/, ""); + if (at === "/m02/structure-definition") return reply({ 판: NOW, types: [] }); + if (at === "/projects/structure-types") return reply({ types: [] }); + if (at.endsWith("/route/structures") && init.method === "PUT") { + const body = JSON.parse(init.body); + puts.push(body.structures.length); + Object.assign(server, { + revision: server.revision + 1, + structures: body.structures, + definition_version: NOW, + }); + return reply({ revision: server.revision }); + } + if (at.endsWith("/route/structures")) return reply({ ...server }); + throw new Error(`모르는 요청 ${at}`); +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request === "@config/config_frontend") return { API_BASE_URL: "", API_TIMEOUT_MS: 1000 }; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + if (request.endsWith(".css")) return {}; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => undefined }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Structures.ts")); + +(async () => { + const P = "p1"; + // 열기 — 종류(정의 판) · 정본(옛 판) → 알림 한 줄 · 옛 판 표시 + await api.fetchStructureTypes(true); + const opened = await api.fetchStructures(P); + const notice = api.staleDefinitionNotice(P, opened.definition_version); + const staleOnOpen = api.definitionStale(P); + // 고침 없음 [저장](B06 기본 창구) — 정본을 그대로 다시 써 판 도장 + await api.flushPendingStructures(P); + const afterSave = { version: server.definition_version, puts: [...puts] }; + // 다시 열기 — 알림 없음 · 또 [저장] 해도 안 나감 + const reopened = await api.fetchStructures(P); + const again = api.staleDefinitionNotice("p1-새탭", reopened.definition_version); + api.staleDefinitionNotice(P, reopened.definition_version); + await api.flushPendingStructures(P); + process.stdout.write( + JSON.stringify({ + notice, + staleOnOpen, + afterSave, + again, + staleAfter: api.definitionStale(P), + putsAfterSecond: puts.length, + structures: server.structures, + }), + ); +})(); diff --git a/resources/tester/helper_323_9_save_redraw.cjs b/resources/tester/helper_323_9_save_redraw.cjs new file mode 100644 index 000000000..66759a9c3 --- /dev/null +++ b/resources/tester/helper_323_9_save_redraw.cjs @@ -0,0 +1,129 @@ +/* PLAN 323-9 ① — B06 [저장] 뒤 카드 = 서버 새 상세. 진짜 `design_save.ts`(저장 차례) · + * `B06_Section_UI_Page_Persist.ts`(횡단 뒤처리) · `B06_Section_Section_Store.ts`(상세 캐시)를 Node 로 돌림. + * 서버 창구만 가짜 — 상세는 저장 전(옛 자리) · 저장 뒤(새 자리) 두 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const memory = () => { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +}; +globalThis.window = { sessionStorage: memory(), localStorage: memory() }; +globalThis.sessionStorage = globalThis.window.sessionStorage; + +const calls = []; +let server = "옛 자리 4+5"; +const detail = () => ({ cross_sections: [], longitudinal: { stations: [] }, tag: server }); +const B06 = path.join(ROOT, "B06_Section"); +const STUBS = { + "@ui/ui_template_elements": { + showToast: (_text, kind) => calls.push(`toast:${kind}`), + showLoadingOverlay: () => calls.push("overlay:on"), + hideLoadingOverlay: () => calls.push("overlay:off"), + }, + [path.join(B06, "B06_Section_Api_Fetch.ts")]: { + fetchSectionDetail: async () => (calls.push(`fetch:${server}`), detail()), + saveSections: async () => calls.push("cross:save"), + confirmSections: async () => calls.push("cross:confirm"), + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (STUBS[full]) return STUBS[full]; + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + if (request.endsWith(".css")) return {}; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => undefined }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { saveDesign, savingDesign } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_save.ts"), +); +const { crossSaveParts } = loadTs(path.join(B06, "B06_Section_UI_Page_Persist.ts")); +const { loadSectionDetail } = loadTs(path.join(B06, "B06_Section_Section_Store.ts")); + +(async () => { + const P = "p1"; + const R = 7; + // 횡단 보기 = 바뀜 신호마다 `refreshDetailForStructures` 와 같은 차례(최신만 · 저장 도중 안 그림) + let shown = null; + let seq = 0; + const refresh = async () => { + const mine = ++seq; + if (savingDesign(P)) return calls.push("refresh:skip"); + const got = await loadSectionDetail(P, R); + if (mine === seq) shown = got.tag; + }; + await refresh(); // 저장 전 화면(캐시 = 옛 서버) + const before = shown; + const ctx = { + projectId: P, + routeId: () => R, + detail: () => null, + context: () => null, + standardValues: () => undefined, + flushCulvertOptions: async () => undefined, + patchSources: () => + Object.fromEntries( + ["rockOffsets", "cutSlopeRatios", "stationWidths", "inletStructures", "basinAdjustments"] + .concat(["revetAdjusts", "extraCounts", "extraSpans", "fordAdjusts", "boxAdjusts"]) + .concat(["linkFlags"]) + .map((key) => [key, new Map()]), + ), + reconcileDesigns: async () => undefined, + redraw: refresh, + }; + const parts = crossSaveParts(ctx); + calls.length = 0; + const savingSeen = []; + const ok = await saveDesign({ + projectId: P, + routeId: () => R, + steps: { + // 관 · 구조물 초안이 정본으로 나가며 가게가 바뀜 → 바뀜 신호(저장 도중) + structures: async () => { + server = "새 자리 4+8 · 47+0"; + savingSeen.push(savingDesign(P)); + await refresh(); + }, + ...parts.steps, + }, + cross: (confirm) => parts.cross(confirm), + done: async () => { + savingSeen.push(savingDesign(P)); + await parts.done(false); + }, + failed: (error) => calls.push(`failed:${error}`), + }); + process.stdout.write( + JSON.stringify({ ok, before, after: shown, savingSeen, calls, savingIdle: savingDesign(P) }), + ); +})(); diff --git a/resources/tester/helper_323_9_twin_cards_3d.cjs b/resources/tester/helper_323_9_twin_cards_3d.cjs new file mode 100644 index 000000000..437064ae8 --- /dev/null +++ b/resources/tester/helper_323_9_twin_cards_3d.cjs @@ -0,0 +1,92 @@ +/* PLAN 323-9 ④-② — 같은 시설을 그리는 카드 둘(같은 m · 노선 끝 EP 꼴 소수 차 0.01 m)이면 3D 관 · 세월교 · BOX 몸체는 한 번. + * 화면 TS(`buildCorridorStructures`)를 그 자리에서 트랜스파일해 Node 로 돌림(로더 = helper_135_3 과 같음). + * 입력 = 73-4 픽스처(세월교 173.087 · BOX 352.14) · 결과 = 표준출력 JSON. 짝 = test_323_9_twin_cards_3d.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + three: require(path.join(ROOT, "config", "node_modules", "three")), +}; +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const { buildCorridorStructures } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures.ts"), +); +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const at = (c) => clone(fixture.cross_sections.find((s) => Math.abs(s.chainage_m - c) < 0.6)); +/** 직선 노선 — 가장 가까운 측점 프레임에서 앞쪽으로 편다. */ +const frameAtOf = (sections) => (atM) => { + const s = sections.reduce((b, x) => + Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b, + ); + const [lx, ly] = s.frame.left_xy; + const along = atM - s.chainage_m; + return { + cx: s.center_x - ly * along, + cy: s.center_y + lx * along, + leftX: lx, + leftY: ly, + designZ: s.design?.design_elevation_m ?? null, + }; +}; +/** 부재 차림표 — 종류 · 열쇠 · 측점(같은 몸체를 두 번 세우면 같은 줄이 둘). */ +const kinds = (sections) => + buildCorridorStructures(sections, frameAtOf(sections)) + .map((s) => `${s.kind}:${s.key ?? ""}:${s.chainage_m.toFixed(2)}`) + .sort(); +const pick = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Pick.ts")); +const own = pick.ownsBody; +/** 노선 끝 꼴 — 같은 시설을 그리는 카드가 소수 차(EP 1078 · 1078.010813)로 하나 더. */ +const ep = (one) => Object.assign(clone(one), { chainage_m: one.chainage_m + 0.010813 }); +const out = {}; +for (const c of [173.087, 352.14]) { + const one = at(c); + const twin = kinds([one, clone(one)]); + const epTwin = kinds([one, ep(one)]); + const epFirst = kinds([ep(one), one]); // 차례가 바뀌어도 시설 자리에 가까운 카드 하나 + pick.ownsBody = () => true; // 고치기 전(카드마다 몸체) — 기준이 변경을 재는지 + const before = kinds([one, clone(one)]); + const epBefore = kinds([one, ep(one)]); + pick.ownsBody = own; + out[c] = { one: kinds([one]), twin, before, epTwin, epFirst, epBefore }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_54_10_soil_tiers.cjs b/resources/tester/helper_54_10_soil_tiers.cjs index aea1daad9..536bc2821 100644 --- a/resources/tester/helper_54_10_soil_tiers.cjs +++ b/resources/tester/helper_54_10_soil_tiers.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -105,6 +106,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request.startsWith("@util/")) return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); diff --git a/resources/tester/helper_54_12_live_required.cjs b/resources/tester/helper_54_12_live_required.cjs index c0e2a1c68..7d322b0db 100644 --- a/resources/tester/helper_54_12_live_required.cjs +++ b/resources/tester/helper_54_12_live_required.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -31,6 +32,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); diff --git a/resources/tester/helper_54_14_balloon_lines.cjs b/resources/tester/helper_54_14_balloon_lines.cjs index 5816c7935..ba749b1c2 100644 --- a/resources/tester/helper_54_14_balloon_lines.cjs +++ b/resources/tester/helper_54_14_balloon_lines.cjs @@ -5,6 +5,7 @@ */ const fs = require("fs"); const path = require("path"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); @@ -19,6 +20,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; // 구조물 표(본보기 · 323-7) if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); return require(request); }; diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs index 3637338be..3e60b790d 100644 --- a/resources/tester/helper_54_27_soil_tier_field.cjs +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -152,6 +153,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request.startsWith("@util/")) return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); diff --git a/resources/tester/helper_b05_station_roundtrip.cjs b/resources/tester/helper_b05_station_roundtrip.cjs index fee14a6c5..ab81f8aef 100644 --- a/resources/tester/helper_b05_station_roundtrip.cjs +++ b/resources/tester/helper_b05_station_roundtrip.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -18,9 +19,11 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => - request.startsWith(".") - ? loadTs(path.join(path.dirname(full), `${request}.ts`)) - : require(request); + CATALOG.isCatalogRequest(request) + ? CATALOG + : request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); new Function("exports", "module", "require", js)(box.exports, box, localRequire); cache.set(full, box.exports); return box.exports; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index f295049b1..3e5bc6e52 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -123,6 +124,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); diff --git a/resources/tester/helper_b06_adjust_single_form.cjs b/resources/tester/helper_b06_adjust_single_form.cjs index 4e71e340e..ec94f4aaf 100644 --- a/resources/tester/helper_b06_adjust_single_form.cjs +++ b/resources/tester/helper_b06_adjust_single_form.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -101,6 +102,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; if (request.startsWith("@util/")) return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); diff --git a/resources/tester/helper_b06_draft_pipe_replay.cjs b/resources/tester/helper_b06_draft_pipe_replay.cjs index a8bd8f1e9..df2316c72 100644 --- a/resources/tester/helper_b06_draft_pipe_replay.cjs +++ b/resources/tester/helper_b06_draft_pipe_replay.cjs @@ -1,5 +1,6 @@ -/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. - * 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다. +/* 관 초안 옵션 되얹기 단위검증 헬퍼(PLAN 24-7a → 323-6) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 새로고침 뒤 캐시 상세(저장분)에 저장 전에 고친 관 옵션(월류 폭 · 관경)이 다시 얹히는지 본다 — 옛 되얹기 + * (`replayDraftPipeOptions`)는 걷고 형상 얹기 한 벌(`attachShapes` · 초안 현황)이 맡음(323-6). * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. * * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. @@ -7,6 +8,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ROOT = path.join(__dirname, "..", ".."); const cache = new Map(); @@ -30,6 +32,7 @@ function loadTs(file) { const box = { exports: {} }; cache.set(full, box.exports); const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; // 화면 공용 부품(@ui/…)은 이 시험이 안 쓰는 것이라 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); if (request.startsWith("@util/")) @@ -45,50 +48,45 @@ function loadTs(file) { return box.exports; } -const options = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts")); +const DEF = JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), +); +const TYPES = DEF.types.map((t) => ({ managed_by: null, enabled: true, ...t })); +const attach = loadTs(path.join(ROOT, "common_util", "common_util_structure_attach.ts")); -// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 옹벽 없음 +// 캐시 상세(저장분) — 세월교(월류 폭 10) · 배수관(관경 1000) · 조정창 정본(기슭막이 조정값) +const adjust = { outlet: { x: 0.4, d: null, h: 2.0, m: null } }; const detail = { cross_sections: [ { chainage_m: 85.05, culvert: { pipe_kind: "파형강관", diameter_m: 1.0, inlet: {}, outlet: {} }, + design: { revet_adjust: adjust }, }, { chainage_m: 173.09, ford: { span_m: 10, diameter_m: 1.0, pipe_count: 1, wing_in: {}, wing_out: {} }, }, + { chainage_m: 258.12 }, ], }; -const sent = []; -const ctx = { - detail: () => detail, - ford: { - setPipe: (chainageM, patch) => { - sent.push({ chainageM, patch }); - if (patch.ford_width_m) detail.cross_sections[1].ford.span_m = patch.ford_width_m; - }, - setWing: () => {}, - }, - box: {}, - revetOffset: { update: () => sent.push({ revet: true }) }, - overrideOptions: () => {}, - refreshCard: () => {}, -}; -// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 되살리지 않음) -options.replayDraftPipeOptions(ctx, [ - { chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } }, - { chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } }, - { chainage_m: 258.12, options: {} }, -]); -options.replayDraftPipeOptions(ctx, null); +// 초안: 세월교 월류 폭 3.5 · 배수관 관경 1200 · 기슭막이 높이(조정창 정본이라 조정값은 안 건드림) +attach.attachShapes( + detail.cross_sections, + [ + { chainage_m: 85.05, options: { pipe_diameter_mm: 1200, outlet_revet_height_m: 2.5 } }, + { chainage_m: 173.09, facility: "ford_bridge", options: { ford_width_m: 3.5 } }, + ], + [], + TYPES, +); process.stdout.write( JSON.stringify({ fordSpan: detail.cross_sections[1].ford.span_m, culvertDiameter: detail.cross_sections[0].culvert.diameter_m, - fordCalls: sent.filter((s) => s.chainageM === 173.09).length, - revetTouched: sent.some((s) => s.revet === true), + fordAt: detail.cross_sections.filter((s) => s.ford).map((s) => s.chainage_m), + revetTouched: detail.cross_sections[0].design.revet_adjust !== adjust, }), ); diff --git a/resources/tester/helper_m02_so_def_spec.cjs b/resources/tester/helper_m02_so_def_spec.cjs index 6634b447e..a3c167ce4 100644 --- a/resources/tester/helper_m02_so_def_spec.cjs +++ b/resources/tester/helper_m02_so_def_spec.cjs @@ -3,6 +3,7 @@ * 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */ const fs = require("fs"); const path = require("path"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); @@ -16,9 +17,11 @@ const load = (...parts) => { }).outputText; const box = (cache[file] = { exports: {} }); const near = (id) => - id.startsWith(".") - ? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`))) - : require(id); + CATALOG.isCatalogRequest(id) // 구조물 표(본보기 · 323-7) + ? CATALOG + : id.startsWith(".") + ? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`))) + : require(id); new Function("exports", "module", "require", js)(box.exports, box, near); return box.exports; }; diff --git a/resources/tester/test_165_pavement_flow.py b/resources/tester/test_165_pavement_flow.py index f13aa4ab6..8ce44b2f3 100644 --- a/resources/tester/test_165_pavement_flow.py +++ b/resources/tester/test_165_pavement_flow.py @@ -116,7 +116,9 @@ def test_배선(): assert "openId" not in panel # 범위 안 빈 카드 = 연 노면공 지킴(165-1) 없앰 · 189-1 # 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔) at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);") - assert at < panel.index("deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);", at) + assert at < panel.index( + "deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));", at + ) view = _read("B06_Section", "B06_Section_UI_Section_View.ts") assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view assert "held" not in view # 범위 카드 재누름 = 그대로(167-3) 없앰 · 189-1 diff --git a/resources/tester/test_178_3_slope_tiers.py b/resources/tester/test_178_3_slope_tiers.py index 85fc6bbdf..4e3831625 100644 --- a/resources/tester/test_178_3_slope_tiers.py +++ b/resources/tester/test_178_3_slope_tiers.py @@ -128,7 +128,8 @@ def test_횡단_형상_단_수_단마다_형태_높이(out): def test_배선(): spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts") - assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec + # 명세 · 덧붙이 명세 = 본보기 키로(323-7 · 복사한 종류도 같은 상자) + assert "specFor(type.type_id) ?? ADDON_SPECS[baseType(type.type_id)]" in spec walls = _read("common_util", "common_util_structure_walls.ts") assert "파이썬 `tier_extras` 짝" in walls engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py") diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py index 82181a3d4..f7b7cc380 100644 --- a/resources/tester/test_272_1_station_list.py +++ b/resources/tester/test_272_1_station_list.py @@ -41,6 +41,7 @@ PIPES = [ }, ] STRUCTURES = [ + # 노출형 = 구조물 표 횡단도 none(322-1 ★1) — 측점 안 심음 {"type_id": "cross_drain_exposed", "placement": "point", "chainage_m": 120.0}, { "type_id": "retaining_wall", @@ -49,7 +50,7 @@ STRUCTURES = [ "start_m": 500.0, "end_m": 530.0, }, - { # 옹벽 끝 530 과 같은 자리에서 시작 — 겹친 자리도 그대로 셈(합치기는 측점 만들기 몫) + { # 옹벽 끝 530 과 같은 자리에서 시작 — 같은 자리는 측점 하나(322-3 측점 계획 · 이름 = 앞선 옹벽) "type_id": "berm", "placement": "interval", "chainage_m": 545.0, @@ -66,7 +67,7 @@ STRUCTURES = [ }, {"type_id": "evac_road", "placement": "point", "chainage_m": 720.0}, ] -EXPECTED = [95, 120, 160, 230, 300, 395, 400, 410, 500, 510, 530, 530, 545, 560] +EXPECTED = [95, 160, 230, 300, 395, 400, 410, 500, 510, 530, 545, 560] # 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) · # 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35 diff --git a/resources/tester/test_298_add_reanalyze.py b/resources/tester/test_298_add_reanalyze.py index 0429a2dd3..8fcfe4b8b 100644 --- a/resources/tester/test_298_add_reanalyze.py +++ b/resources/tester/test_298_add_reanalyze.py @@ -65,8 +65,7 @@ def test_B06_추가_순간_기다림_적고_초안_씀(): body.index("pipeFacilities.push({"), body.index("if (waitsFordHeight(attributes.facility, attributes.options))"), body.index("fordWaitsOf(deps.projectId).add(at);"), - body.index("persistPipes();"), - body.index("deps.onStructuresChanged?.();"), + body.index("persistPipes();"), # 초안 씀 = 카드 다시(바뀜 신호 하나 · 323-6) ] assert order == sorted(order) @@ -145,8 +144,11 @@ def test_실패하면_토스트_한_줄(out): def test_추가_뒤_새_측점_횡단_미리_뜸(): body = _body(B06, " onPipeAdd: (chainageM, attributes) => {", " onPipeUpdate:") - assert "deps.onStructuresChanged?.();" in body - assert "onStructuresChanged: () => void refreshDetailForStructures()," in CROSS + assert "persistPipes();" in body # 초안 씀 → 구독(`watchDrafts`)이 카드 다시(323-6) + # 카드 다시 = 바뀜 신호 하나(323-6 · 관 · 구조물 초안 구독 · 옛 되부름 갈음) + assert ( + "watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures())" in CROSS + ) drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") stations = _body( drafts, "export async function withDraftStations(", "parked.set(detail, stale);" diff --git a/resources/tester/test_307_pick_highlight.py b/resources/tester/test_307_pick_highlight.py index e14f611de..1f047f337 100644 --- a/resources/tester/test_307_pick_highlight.py +++ b/resources/tester/test_307_pick_highlight.py @@ -60,13 +60,20 @@ def test_물넘이포장_측점길은_콘크리트_안켬(out): view = _read("B06_Section", "B06_Section_UI_Section_View.ts") assert "highlightShapes(card, pickedMarkId);" in view shape = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts") - assert 'if (id.startsWith("pipe-")) continue;' in shape + assert "paintPicked(root, { structureId })" in shape # 고름 강조 한 규칙(323-5) + paint = _read("A00_Common", "design", "design_pick_match.ts") + assert ( + 'if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;' in paint + ) # 네 길이 모이는 자리 — 카드 고름 리스너 · 목록 관 줄 · 폼 관 열기 · 측점 없는 관 cross = _read("A00_Common", "design", "design_view_cross.ts") assert "sectionView.selectMark(mark, false);" in cross panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") - assert "deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);" in panel # 카드 → 폼 - assert "deps.markStructure?.(chainageM === null ? null : `pipe-${" in panel # 목록 관 줄 + # 알약 id = 관 세션 id(323-5) + assert ( + "deps.markStructure?.(pipeIdAt(hit.pipeChainageM, deps.projectId));" in panel + ) # 카드 → 폼 + assert "deps.markStructure?.(chainageM === null ? null : pipeIdAt(" in panel # 목록 관 줄 # 측점 길은 「관」 벽 고름을 안 엶 — 카드만 고르면 벽 고름을 비움(그대로) assert "stationControls.revetOffset.select(target.chainage_m, null);" in cross diff --git a/resources/tester/test_312_empty_pipes.py b/resources/tester/test_312_empty_pipes.py index d41809943..1b632311b 100644 --- a/resources/tester/test_312_empty_pipes.py +++ b/resources/tester/test_312_empty_pipes.py @@ -158,11 +158,11 @@ def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None: def test_횡단_벽_초안_빈_목록은_다_지움_그리고_옛_결함_막이() -> None: - store = _body( - "B06_Section/B06_Section_Section_Store.ts", "async function withDraftWalls(", "\n}\n" - ) - assert "if (!drafts) return withStructureAreas(detail);" in store - assert "!drafts.length" not in store + # 빈 목록 = 다 지움 — 현황 = 초안 `??` 저장분(빈 목록도 초안) · 형상 얹기가 모든 열쇠를 걷고 다시(323-4) + drafts = _read("B06_Section/B06_Section_Section_Drafts.ts") + assert "structuresDraft ?? savedOf(projectId)" in drafts + assert "const drafts = !!(pipesDraft || structuresDraft);" in drafts + assert "!drafts.length" not in drafts # 옛 「놓아도 안 보임」(09-09) — 옛 배열 참조가 `[]` 를 알리던 것. 정본 주입은 배열 안을 # 채우고(갈아끼우지 않음) onChange 를 울리지 않아야 함. inject = _body( diff --git a/resources/tester/test_313_clear_all.py b/resources/tester/test_313_clear_all.py index 4e5285c56..dcc7f15cd 100644 --- a/resources/tester/test_313_clear_all.py +++ b/resources/tester/test_313_clear_all.py @@ -83,7 +83,7 @@ def test_B05_B06_같은_목록_각자_관빼기(): assert "onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage)," in b05 # 공용 확인창 · 저장 길(초안 → [저장])은 안 고침 events = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Events.ts") - assert 'import { showConfirmDialog } from "@ui/ui_template_elements";' in events + assert 'import { showConfirmDialog, showToast } from "@ui/ui_template_elements";' in events assert "ctx.emit();" in events diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py index 0b680b005..41be0ff25 100644 --- a/resources/tester/test_314_stale_structure.py +++ b/resources/tester/test_314_stale_structure.py @@ -74,8 +74,9 @@ def test_structure_change_reconciles_and_ford_reattach() -> None: assert "void reconcileStaleDesigns();" in body refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") assert "Number(section.ford?.overflow_depth_m) > 0" in refresh - store = _read("B06_Section", "B06_Section_Section_Store.ts") - kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0] - assert "if (!pipes) return;" in kinds - assert "delete section.ford_pavement" in kinds - assert 'if (!here.length) return pipe.facility === "ford_pavement";' in kinds + # 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 · + # 동작 시험 = test_323_4_attach_mirror) + drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") + assert "attachShapes(" in drafts + keys = _read("common_util", "common_util_structure_shape_keys.ts") + assert '"ford_pavement",' in keys diff --git a/resources/tester/test_318_cross_card.py b/resources/tester/test_318_cross_card.py index 61e759fe6..456589481 100644 --- a/resources/tester/test_318_cross_card.py +++ b/resources/tester/test_318_cross_card.py @@ -78,7 +78,11 @@ def test_center_tag_on_y_axis(result: dict) -> None: assert box["x"] > 0 assert abs(box["y"] + box["height"] / 2 - center["center_y"]) < 0.01 # 중심고 높이에 assert center["text_x"] == center["tick_x"] # 글 오른끝 = 다른 눈금 자리 - assert center["tick_near_center"] == 0 # 겹치는 눈금 글은 비켜 줌 + assert center["tick_near_center"] >= 1 # 겹친 눈금 글도 그대로(319-1) + assert center["on_top"] == [ # 상자 · 글을 맨 뒤에 붙여 위에서 가림(319-1) + "b06-chart__center-box", + "b06-chart__tick b06-chart__center-text", + ] assert center["tick_count"] > 3 assert center["hidden_when_out_of_view"] assert center["absent_without_center"] diff --git a/resources/tester/test_319_kind_swap.py b/resources/tester/test_319_kind_swap.py new file mode 100644 index 000000000..25e54e60b --- /dev/null +++ b/resources/tester/test_319_kind_swap.py @@ -0,0 +1,141 @@ +"""PLAN 319 — 횡단배수 종류 바꿈이 맹암거 · 개거처럼 정본이 다른 종류에서도 먹음(요구 217). + +- 패널: 관 지점 ↔ 구조물로 바꿈 = 옛 것 지움(「없음」) → 같은 측점에 새 종류(우클릭 추가 길 `addAt`) + · 그 측점 옛 조정값 버림 · 처음 종류로 되돌리면 비울 자리 기록이 빠짐 · 같은 정본끼리는 종전 그대로 +- 횡단 초안 맞춤: 일반 측점 자리(grid)도 종류가 바뀌거나 빠지면 구조물만 뺀 사본(측점 남음 · 고친 값 + 그대로)에 새 종류 · 저장분 종류로 되돌리면 저장분 그대로 + +실제 TS 를 `helper_319_kind_swap.cjs` 가 Node 로 돌린다(서버 부르는 곳 흉내). +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def result() -> dict: + out = subprocess.run( + ["node", os.path.join(HERE, "helper_319_kind_swap.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _src(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +def test_pipe_to_underdrain_removes_pipe_and_places_at_same_station(result: dict) -> None: + swap = result["pipe_to_underdrain"] + assert swap["removed"] == [80] + assert swap["pipeSet"] == [] # 새 것이 놓인 뒤 옛 관만 지움(고름은 새 것 · 323-6 고침) + assert swap["addAt"] == [[80, "cross_underdrain"]] + assert swap["reset"] == 0 and swap["live"] == 0 # 새로 놓기 폼으로 빠지지 않음 + assert swap["kindreset"] == {"80.00": "pipe"} # [저장] 때 그 측점 옛 조정값 비움(318-4) + assert swap["culvertopt"] == {"300.00": {"b": 2}} # 그 측점 세션 조정값만 버림 + + +def test_back_to_first_kind_drops_reset_mark(result: dict) -> None: + back = result["underdrain_back_to_pipe"] + assert back["left"] == [] and back["emit"] == 1 and back["editingSet"] == [] + assert back["addAt"] == [[80, "pipe"]] + assert back["kindreset"] is None + + +def test_structure_to_pipe_kind(result: dict) -> None: + swap = result["open_ditch_to_box"] + assert swap["left"] == ["sign"] # 고른 개거만 빠짐 + assert swap["removed"] == [] + assert swap["addAt"] == [[120, "box_culvert"]] + assert swap["kindreset"]["120.00"] == "open_ditch" + + +def test_same_owner_switch_unchanged(result: dict) -> None: + for case in ("pipe_to_box", "underdrain_to_open_ditch"): + same = result[case] + assert same["addAt"] == [] and same["removed"] == [] and same["live"] == 1 + assert result["to_blank"]["reset"] == 1 and result["to_blank"]["addAt"] == [] + + +def test_point_interval_switch_replaces_at_same_anchor(result: dict) -> None: + # 323-6 — 표 규칙(`swapsInPlace`) · 구간 = 기본 길이(`addAt`) + up = result["point_to_interval"] + assert up["left"] == [] and up["addAt"] == [[140, "guardrail"]] and up["live"] == 0 + down = result["interval_to_point"] + assert down["left"] == [] and down["addAt"] == [[105, "mirror"]] + same = result["interval_same"] + assert same["addAt"] == [] and same["live"] == 1 and same["left"] == ["work_yard"] + + +def test_replace_keeps_old_when_new_cannot_be_placed(result: dict) -> None: + # 반사경 → 가드레일(빈 칸 때문에 바로 못 놓음) = 반사경 · 반사경 폼 그대로 + 알림 한 줄(조용히 사라지던 것) + kept = result["replace_blocked"] + assert kept["left"] == ["mirror"] and kept["addAt"] == [[300, "guardrail"]] + assert kept["loaded"] == ["mirror"] and kept["emit"] == 0 + assert len(kept["toasts"]) == 1 and "반사경" in kept["toasts"][0][0] + pipe = result["replace_blocked_pipe"] + assert pipe["removed"] == [] and pipe["loaded"] == ["pipe@200"] and len(pipe["toasts"]) == 1 + + +def test_blocked_switch_restores_with_notice(result: dict) -> None: + blocked = result["blocked"] + assert blocked["left"] == ["mirror"] and blocked["loaded"] == ["mirror"] + assert len(blocked["toasts"]) == 1 and blocked["toasts"][0][1] == "error" + + +def test_grid_station_kind_change_uses_bare_copy(result: dict) -> None: + # 일반 측점 자리 = 측점이 남음 — 저장분에서 구조물만 뺀 사본에 새 종류(지형 쪽 고친 값 그대로) + grid = result["grid"] + assert grid["pipe_to_underdrain"] == { + "count": 3, + "keys": [], + "structure": "맹암거", + "preview": True, + "fresh": False, + "keep": 1, + } + assert grid["pipe_to_box"]["keys"] == ["box"] and grid["pipe_to_box"]["keep"] == 1 + assert grid["underdrain_to_pipe"]["keys"] == ["culvert"] + assert grid["underdrain_to_pipe"]["structure"] == "파형강관 D800" + assert grid["underdrain_to_pipe"]["keep"] == 1 + + +def test_grid_station_emptied_keeps_station_without_facility(result: dict) -> None: + assert result["grid"]["pipe_deleted"] == { + "count": 3, + "keys": [], + "structure": None, + "preview": True, + "fresh": False, + "keep": 1, + } + + +def test_grid_station_back_to_saved_kind_restores_saved(result: dict) -> None: + back = result["grid"]["pipe_back"] + assert back["keys"] == ["culvert"] and not back["preview"] and back["keep"] == 1 + + +def test_panel_wires_add_at() -> None: + panel = _src("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + assert "addAt: (at, typeId) => api.addAt(at, typeId)" in panel + assert "return api;" in panel + assert len(panel.splitlines()) <= 700 diff --git a/resources/tester/test_322_0_ghost.py b/resources/tester/test_322_0_ghost.py new file mode 100644 index 000000000..b0e8749f0 --- /dev/null +++ b/resources/tester/test_322_0_ghost.py @@ -0,0 +1,68 @@ +"""PLAN 322-0 ② → 323-7 — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 안 되옮김. + +정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꿈 → 지금 관 · 구조물이 심는 자리(측점 +계획)의 측점은 옛 이름 · 새 이름 모두 이관 0건(323-7 · 자리로 가름 · 지난 판 이름 되짚기 걷음). +옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 심는 자리가 아니면 종전대로 옮김. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest + +from B05_Profile import B05_Profile_Structures_Schema as schema +from B05_Profile.B05_Profile_Structures_Migration import migrate_irregular_stations + +MASTER = Path(schema.DEFINITION_PATH) +RENAMED = { + "cross_drain_exposed": "노출수로", + "box_culvert": "박스암거", + "retaining_wall": "옹벽(신)", +} + + +@pytest.fixture() +def renamed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + old = json.loads(MASTER.read_text(encoding="utf-8")) + history = tmp_path / schema.HISTORY_DIRNAME + history.mkdir() + shutil.copy(MASTER, history / f"판_{old['판']}.json") + new = {**old, "판": old["판"] + 1} + new["types"] = [{**t, "name": RENAMED.get(t["type_id"], t["name"])} for t in old["types"]] + path = tmp_path / "구조물정의.json" + path.write_text(json.dumps(new, ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return old + + +def _moved(label: str, planted: tuple[float, ...] = ()) -> int: + plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}], planted) + return len(plan.structures) + len(plan.pipe_facilities) + + +def test_old_and_new_names_at_planted_spots_are_not_migrated(renamed: dict) -> None: + # 323-7 — 자리로 가름(지금 관 · 구조물이 심는 자리) · 이름을 바꿔도 자리는 그대로 + assert schema.structure_type_map()["box_culvert"].name == "박스암거" # 사본을 읽음 + for type_id, name in RENAMED.items(): + old_name = next(t["name"] for t in renamed["types"] if t["type_id"] == type_id) + assert _moved(old_name, (120.0,)) == 0, old_name + assert _moved(name, (120.0,)) == 0, name + assert _moved(name) == 0, name # 지금 판 이름은 자리 없이도 + + +def test_history_names_are_not_read(renamed: dict) -> None: + # 옛 이름 되짚기 걷음(323-7) — 자리를 못 받은 옛 이름 = 옛 자유 글처럼 「기타」 + assert not hasattr(schema, "all_type_names") + assert _moved("노출형 횡단수로") == 1 + + +def test_legacy_free_text_still_migrates(renamed: dict) -> None: + assert _moved("기성막이") == 1 # 옛 오기 → 기슭막이(관 지점) + plan = migrate_irregular_stations([{"chainage_m": 300.0, "structure": "대피로 2.0m"}]) + assert [s.type_id for s in plan.structures] == ["refuge"] + plan = migrate_irregular_stations([{"chainage_m": 15.0, "structure": "돌망태 보강"}]) + assert [(s.type_id, s.options["name"]) for s in plan.structures] == [("etc", "돌망태 보강")] + assert _moved("돌망태 보강", (120.004,)) == 0 # 심는 자리면 글이 무엇이든 안 옮김 diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py new file mode 100644 index 000000000..545bbde74 --- /dev/null +++ b/resources/tester/test_322_0_golden.py @@ -0,0 +1,261 @@ +"""PLAN 322-0 전수 표 시험 — 구조물 42종 × 견본마다 일곱 갈래 결과를 기록(`golden_322_structures.json`)과 맞춤. + +갈래 = 심는 측점(TS `structureStationMarks` · `pipeStationMarks` · 파이썬 `resolve_extra_stations`) · 카드 이름 +(파이썬 측점 이름 · TS 미리 뜬 횡단 이름 · 표시 이름) · 형상 얹기 열쇠(파이썬 attach 차례 · TS `Section_Store` 초안 얹기) +· 3D 몸체 · 알약 띠 · 고르기 목록(B05 · B06 `pickGroups`) · 옹벽 물빼기. +견본 = 종류마다 기본값 한 벌(main) · 빈칸(blank) · 배관은 흄관(hume) 하나 더 — 점형 · 부지형 = 101 m · 구간형 = 93 ~ 113 m +(기준 101) · 관 지점 = 101 m(기슭막이 96 ~ 106 m) · 격자 = 0 ~ 200 m 20 m 마다. + +기록 다시 쓰기 = `GOLDEN_322_RECORD=1` · ★ = 계획(PLAN_구조물표 4장)대로 바뀔 칸 — 기록에 「지금 → 다음」을 적고 +`STAGE` 가 그 단계를 넘으면 다음 값과 맞춤. TS 쪽 = `helper_322_0_golden.cjs`(Node). +""" + +from __future__ import annotations + +import copy +import json +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations +from B05_Profile.B05_Profile_Structures_Repository import structures_file_path +from B05_Profile.B05_Profile_Structures_Schema import load_definition +from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets, pipe_points_file +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures +from B06_Section.B06_Section_Engine_Tilt import attach_underdrains +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_pavement import attach_pavement_structures +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent +GOLDEN = HERE / "golden_322_structures.json" +FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json" +#: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤 +STAGE = 4 +GRID = [float(at) for at in range(0, 201, 20)] +SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _plain_section() -> dict: + sections = json.loads(FIXTURE.read_text(encoding="utf-8"))["cross_sections"] + plain = copy.deepcopy(next(s for s in sections if s["chainage_m"] == 180)) + for key in (*SHAPE_KEYS, "structure"): + plain.pop(key, None) + return plain + + +def _samples(definition: dict) -> list[dict]: + out: list[dict] = [] + for item in definition["types"]: + defaults = { + option["key"]: option["default"] + for option in item.get("options", []) + if option.get("default") is not None + } + cases = {"main": defaults, "blank": {}} + if item["type_id"] == "pipe": + cases["hume"] = {**defaults, "pipe_kind": "흄관", "pipe_diameter_mm": "800"} + for case, options in cases.items(): + pipe = structure = None + if item.get("managed_by"): + pipe = {"chainage_m": 101.0, "source": "user", "facility": item["type_id"]} + if item["type_id"] == "revetment": + pipe.update(start_m=96.0, end_m=106.0) + if options: + pipe["options"] = options + else: + structure = { + "structure_id": "s1", + "type_id": item["type_id"], + "placement": item["placement"], + "chainage_m": 101.0, + "options": options, + "memo": "", + "placement_source": "manual", + "status": "draft", + "revision": 0, + "geometry": None, + } + if item["placement"] == "interval": + structure.update(start_m=93.0, end_m=113.0) + out.append( + {"type_id": item["type_id"], "case": case, "pipe": pipe, "structure": structure} + ) + return out + + +def _shape_of(section: dict) -> dict: + """TS 짝 `shapeOf` 와 같은 꼴.""" + out: dict = {} + for key in SHAPE_KEYS: + spec = section.get(key) + if not spec: + continue + if key == "culvert": + out[key] = { + "hidden_pipe": bool(spec.get("hidden_pipe")), + "body": bool(spec.get("body")), + } + elif key == "revetment": + out[key] = {"type_id": spec.get("type_id"), "form": spec.get("form")} + elif key == "pavement": + out[key] = sorted(p["type_id"] for p in spec) + else: + out[key] = True + return out + + +def _python_row(sample: dict, root: Path, plain: dict) -> dict: + """서버 길 — 정본 파일을 쓰고 측점 만들기 · 횡단 얹기 차례(`_read_section_detail`)를 그대로.""" + if sample["structure"]: + path = Path(structures_file_path(str(root))) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"revision": 1, "structures": [sample["structure"]]}), encoding="utf-8" + ) + pipes = parse_pipe_points([sample["pipe"]]) if sample["pipe"] else [] + if sample["pipe"]: + path = pipe_points_file(root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"points": [sample["pipe"]]}), encoding="utf-8") + extras = resolve_extra_stations(root, pipes) + sections = [{**copy.deepcopy(plain), "chainage_m": at, "kind": "regular"} for at in GRID] + for at, label in extras: + hit = next((s for s in sections if abs(s["chainage_m"] - at) < 0.005), None) + if hit is None: + hit = {**copy.deepcopy(plain), "chainage_m": at, "kind": "irregular"} + sections.append(hit) + hit["structure"] = label + sections.sort(key=lambda s: s["chainage_m"]) + attach_culvert_sets(root, sections) + relabel_pipe_stations(root, sections) + attach_wall_structures(root, sections) + attach_pavement_structures(root, sections) + attach_underdrains(root, sections) + return { + "station_py": [f"{label}@{at:.2f}" for at, label in extras], + "cards_py": { + f"{s['chainage_m']:.2f}": {"title": s.get("structure"), "shape": _shape_of(s)} + for s in sections + if s.get("structure") or _shape_of(s) + }, + } + + +@pytest.fixture(scope="module") +def result() -> dict: + with tempfile.TemporaryDirectory() as folder: + return _run(Path(folder)) + + +def _run(work: Path) -> dict: + definition = load_definition() + samples = _samples(definition) + plain = _plain_section() + payload = { + "types": with_visible().model_dump()["types"], + "definition": definition, + "samples": samples, + "grid": GRID, + "plain_section": plain, + } + (work / "input.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_0_golden.cjs"), str(work / "input.json")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + ts = json.loads(out.stdout) + rows: dict = {} + for index, sample in enumerate(samples): + key = f"{sample['type_id']}/{sample['case']}" + rows[key] = {**ts["samples"][key], **_python_row(sample, work / f"p{index}", plain)} + return {"samples": rows, "pick": ts["pick"], "version": ts["version"], "notice": ts["notice"]} + + +def test_type_cache_follows_definition_version(result: dict) -> None: + """322-0 ③ — 화면 종류 캐시: 같은 판이면 그대로 · M02 저장으로 판이 바뀌면 탭이 다시 보일 때 버리고 다시 받음.""" + assert result["version"] == { + "same": False, + "sameCalls": 0, + "reloads": 1, + "staleCalls": 1, + "removed": True, + } + + +def test_stale_definition_notice_once(result: dict) -> None: + """322-2 판 알림 — structures.json 판 ≠ 지금 판이면 한 줄 · 탭 수명 동안 한 번 · 같은 판 · 판 없음은 없음.""" + now = load_definition()["판"] + 1 # 시험 서버가 판을 하나 올린 뒤 + assert result["notice"] == [ + f"구조물 정의가 판 {now} 으로 바뀜 — 저장하면 반영", + None, + None, + None, + ] + + +def _cells(table: dict) -> dict[tuple[str, str, str], object]: + return { + (part, key, column): value + for part in ("samples", "pick") + for key, row in table[part].items() + for column, value in row.items() + } + + +def test_golden_matches(result: dict) -> None: + if os.environ.get("GOLDEN_322_RECORD") == "1": + old = json.loads(GOLDEN.read_text(encoding="utf-8")) if GOLDEN.exists() else {} + GOLDEN.write_text( + json.dumps( + { + "★ 메모": old.get("★ 메모", []), + "★": old.get("★", []), + "samples": result["samples"], + "pick": result["pick"], + }, + ensure_ascii=False, + indent=1, + ) + + "\n", + encoding="utf-8", + ) + golden = json.loads(GOLDEN.read_text(encoding="utf-8")) + star = {(s["part"], s["key"], s["column"]): s for s in golden["★"]} + want = _cells(golden) + got = _cells(result) + assert set(got) == set(want), "견본 · 칸 목록이 기록과 다름" + wrong = [] + for cell, value in want.items(): + mark = star.get(cell) + expect = mark["다음"] if mark and STAGE >= mark["단계"] else value + if got[cell] != expect: + wrong.append(f"{cell}: 기록 {expect!r} · 지금 {got[cell]!r}") + assert not wrong, "\n".join(wrong[:40]) + + +def test_golden_covers_every_type(result: dict) -> None: + types = {item["type_id"] for item in load_definition()["types"]} + assert len(types) == 42 + assert {key.split("/")[0] for key in result["samples"]} == types + assert set(result["pick"]) == types + + +def test_star_cells_exist() -> None: + golden = json.loads(GOLDEN.read_text(encoding="utf-8")) + cells = _cells(golden) + for mark in golden["★"]: + assert (mark["part"], mark["key"], mark["column"]) in cells, mark diff --git a/resources/tester/test_322_0_version.py b/resources/tester/test_322_0_version.py new file mode 100644 index 000000000..2d87b92e8 --- /dev/null +++ b/resources/tester/test_322_0_version.py @@ -0,0 +1,58 @@ +"""PLAN 322-0 ③ — 판 확인: 서버 관 기본값 · 판 읽기 길이 정의 사본을 바로 따름(서버 다시 켜기 없음). + +- `_registry_defaults` = 정의 파일이 바뀌면 다음 부름부터 새 기본값(옛 lru = 서버 다시 켤 때까지 옛 값). +- `GET /api/m02/structure-definition/version` = 지금 판 `{판}` 만(화면 종류 캐시가 판을 볼 때 · 한 벌 안 받음). +화면 캐시 쪽은 `test_322_0_golden.py::test_type_cache_follows_definition_version`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile import B05_Profile_Structures_Schema as schema +from B06_Section.B06_Section_Engine_Culvert import _registry_defaults +from common_util.common_util_auth import verify_session +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + + +@pytest.fixture +def copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """정의 사본 — 마스터 정본은 안 씀.""" + path = tmp_path / "구조물정의.json" + path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return path + + +def _rewrite(path: Path, change) -> None: + doc = json.loads(path.read_text("utf-8")) + change(doc) + path.write_text(json.dumps(doc, ensure_ascii=False), "utf-8") + + +def test_registry_defaults_follow_definition_file(copy: Path) -> None: + before = _registry_defaults("pipe")["pipe_diameter_mm"] + + def bump(doc: dict) -> None: + pipe = next(t for t in doc["types"] if t["type_id"] == "pipe") + option = next(o for o in pipe["options"] if o["key"] == "pipe_diameter_mm") + option["default"] = "1200" if before != "1200" else "800" + + _rewrite(copy, bump) + assert _registry_defaults("pipe")["pipe_diameter_mm"] != before + + +def test_version_endpoint_reads_current_version(copy: Path) -> None: + app = FastAPI() + app.include_router(read_router) + app.dependency_overrides[verify_session] = lambda: {"role": "USER", "user_id": 2} + client = TestClient(app) + now = json.loads(copy.read_text("utf-8"))["판"] + assert client.get("/api/m02/structure-definition/version").json() == {"판": now} + _rewrite(copy, lambda doc: doc.update(판=now + 3)) + assert client.get("/api/m02/structure-definition/version").json() == {"판": now + 3} diff --git a/resources/tester/test_322_1_catalog_mirror.py b/resources/tester/test_322_1_catalog_mirror.py new file mode 100644 index 000000000..05e8f71b0 --- /dev/null +++ b/resources/tester/test_322_1_catalog_mirror.py @@ -0,0 +1,229 @@ +"""PLAN 322-1 구조물 표(catalog) — 부품 상자 · 처음값 · 파이썬 = TS 거울. + +- 거울: 실제 정의(42종) · 반영 덮어쓰기 · 부품 끔 · 정의 없음 — `resolve_catalog` = TS `resolveCatalog` 한 칸도 같음. +- 처음값 = PLAN_구조물표 4장 표(★ 넷 포함) · 부품 id 는 상자에 있음 · 형상 none → 횡단도 none(218). +- 부품 자리꼴 · 정본 = 그 종류 표 줄과 맞음(1-3 규칙 · ★ 기슭막이 interval 이라야 span3 · hidden_revet 에 맞음). +- 카드 이름 한 벌: 빈칸 = 정의 기본값(파이썬 · TS 같은 이름) · 목록 거르기(B05 both · B06 both + b06). +TS 쪽 = `helper_322_1_catalog.cjs`(Node). +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import load_definition +from common_util.common_util_structure_catalog import ( + card_title, + catalog, + load_parts, + resolve_catalog, +) + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +#: PLAN_구조물표 4장 — 종류 → (목록, 횡단도, 카드이름, 형상, 3D) +TABLE = { + "pipe": ("both", "anchor", "pipe", "pipe_set", "pipe"), + "box_culvert": ("both", "anchor", "name", "box", "box"), + "ford_pavement": ("both", "anchor", "name", "ford_pavement", "chute"), + "ford_bridge": ("both", "anchor", "name", "ford", "ford"), + "cross_drain_exposed": ("both", "none", "name", "none", "none"), + "open_ditch": ("both", "none", "name", "none", "none"), + "cross_underdrain": ("both", "anchor", "name", "underdrain", "surface"), + **{ + t: ("hidden", "none", "name", "none", "none") + for t in ( + "ditch_side", + "ditch_ridge", + "ditch_berm", + "chute", + "slope_drain", + "underdrain", + "erosion_check", + "bed_sill", + "check_dam_small", + "refuge", + "work_yard", + "turnaround", + ) + }, + "retaining_wall": ("both", "span3", "name", "wall", "wall"), + "masonry_wet": ("hidden", "span3", "name", "wall", "wall"), + "masonry_dry": ("hidden", "span3", "name", "wall", "wall"), + "soil_guard": ("both", "span3", "name", "wall", "wall"), + "boulder_masonry": ("hidden", "span3", "name", "wall", "wall"), + "berm": ("both", "span3", "name", "berm", "surface"), + "revetment": ("hidden", "span3", "name", "hidden_revet", "wall"), + **{ + t: ("both", "none", "name", "none", "none") + for t in ( + "evac_road", + "mirror", + "curb", + "hazard_sign", + "position_sign", + "water_intake", + "barrier_gate", + "completion_sign", + "guardrail", + "borrow_pit", + "etc", + ) + }, + "spoil_bank": ("both", "none", "name", "spoil", "surface"), + "wildlife_path": ("b06", "none", "name", "none", "none"), + "revegetation": ("b06", "none", "name", "none", "none"), + "pavement_concrete": ("both", "none", "name", "pavement", "surface"), + "gravel_surfacing": ("b06", "none", "name", "pavement", "surface"), +} +FORMS = { + "retaining_wall": {"form": "콘크리트", "weep": True}, + "masonry_wet": {"form": "돌쌓기(찰)"}, + "masonry_dry": {"form": "돌쌓기(메)"}, + "soil_guard": {"form": "통나무·목재틀"}, + "boulder_masonry": {"form": "돌쌓기(메)"}, +} + + +def _cases() -> dict: + real = load_definition() + reflected = copy.deepcopy(real) + reflected["반영"] = { + "open_ditch": {"횡단도": "anchor", "목록": "b06"}, + "pipe": {"카드이름": "name", "3D": "none"}, + "soil_guard": {"형상칸": {"form": "돌쌓기(찰)", "weep": True}}, + "etc": {"목록": "hidden"}, + "wildlife_path": {"목록": "both"}, + } + parts_off = copy.deepcopy(real) + parts_off["부품"] = {"형상.wall": False, "횡단도.span3": False, "카드이름.pipe": False} + renamed = copy.deepcopy(real) + renamed["구조물군"] = dict(reversed(list(real["구조물군"].items()))) + for item in renamed["types"]: + if item["type_id"] == "berm": + item["name"] = "소단(새 이름)" + item["enabled"] = False + return { + "real": real, + "reflected": reflected, + "parts_off": parts_off, + "renamed": renamed, + "none": None, + } + + +TITLES = [ + ["pipe", {}, None], + ["pipe", {"pipe_kind": "흄관", "pipe_diameter_mm": "800"}, None], + ["pipe", {"pipe_diameter_mm": ""}, None], + ["pipe", {"pipe_diameter_mm": 1200.7}, None], + ["box_culvert", {}, None], + ["retaining_wall", {"form": "콘크리트"}, None], +] + + +@pytest.fixture(scope="module") +def ts() -> dict: + definition = load_definition() + defaults = { + t["type_id"]: {o["key"]: o.get("default") for o in t.get("options", [])} + for t in definition["types"] + } + titles = [[tid, options, defaults[tid]] for tid, options, _ in TITLES] + payload = {"cases": _cases(), "definition": definition, "titles": titles} + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_1_catalog.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_mirror_python_equals_ts(ts: dict) -> None: + for name, definition in _cases().items(): + assert resolve_catalog(definition) == ts["cases"][name], name + assert len(ts["cases"]["real"]) == 42 + + +def test_start_values_match_plan_table() -> None: + rows = resolve_catalog(load_definition()) + assert set(rows) == set(TABLE) + for type_id, (listed, station, title, shape, model3d) in TABLE.items(): + row = rows[type_id] + got = (row["list"], row["station"], row["title"], row["shape"], row["model3d"]) + assert got == (listed, station, title, shape, model3d), type_id + assert row["shapeOpts"] == FORMS.get(type_id, {}), type_id + assert rows["revetment"]["placement"] == "interval" # ★ 표만(정의 파일 = point 그대로) + assert [t["placement"] for t in load_definition()["types"] if t["type_id"] == "revetment"] == [ + "point" + ] + + +def test_parts_box_is_consistent() -> None: + parts = load_parts() + branches = {"횡단도", "카드이름", "형상", "3D"} + assert set(parts["parts"]) == branches + for type_id, start in parts["처음값"].items(): + for branch in branches: + value = start[branch] + assert value in parts["parts"][branch] or value in ("none", "name"), (type_id, value) + if start["형상"] == "none": # 그림 없는 종류는 횡단도 안 만듦(218) + assert start["횡단도"] == "none", type_id + rows = resolve_catalog(load_definition()) + for type_id, row in rows.items(): # 부품 자리꼴 · 정본 = 종류 표 줄(1-3) + for branch, value in (("횡단도", row["station"]), ("형상", row["shape"])): + part = parts["parts"][branch].get(value) + if part is None: + continue + assert row["placement"] in part["자리꼴"], (type_id, branch, value) + assert row["owner"] in part["정본"], (type_id, branch, value) + + +def test_reflect_and_parts_off_win() -> None: + cases = _cases() + reflected = resolve_catalog(cases["reflected"]) + assert reflected["open_ditch"]["station"] == "anchor" + assert reflected["open_ditch"]["list"] == "b06" + assert reflected["pipe"]["title"] == "name" and reflected["pipe"]["model3d"] == "none" + assert reflected["soil_guard"]["shapeOpts"] == {"form": "돌쌓기(찰)", "weep": True} + assert reflected["wildlife_path"]["list"] == "both" + off = resolve_catalog(cases["parts_off"]) + assert off["retaining_wall"]["shape"] == "none" and off["retaining_wall"]["station"] == "none" + assert off["pipe"]["title"] == "name" and off["pipe"]["station"] == "anchor" + renamed = resolve_catalog(cases["renamed"]) + assert renamed["berm"]["name"] == "소단(새 이름)" and renamed["berm"]["list"] == "b06" + assert renamed["etc"]["groupOrder"] == 0 # 군 차례 = 구조물군 열쇠 차례 + + +def test_card_title_one_formula(ts: dict) -> None: + py = [card_title(tid, options) for tid, options, _ in TITLES] + assert py == ts["titles"] + assert py[0] == "파형강관 D1000" and py[2] == "파형강관 D1000" # 빈칸 = 정의 기본값(옛 D800) + assert py[1] == "흄관 D800" and py[3] == "파형강관 D1200" + assert py[4] == "BOX암거" and py[5] == "옹벽" + assert ts["kinds"] == sorted({"흄관", "VR관", "파형강관"}) + + +def test_list_pick_pages(ts: dict) -> None: + rows = catalog() + for type_id, (b05, b06) in ts["pick"].items(): + listed = rows[type_id]["list"] + assert b05 == (listed == "both"), type_id + assert b06 == (listed in ("both", "b06")), type_id diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py new file mode 100644 index 000000000..819de5f30 --- /dev/null +++ b/resources/tester/test_322_2_check_mirror.py @@ -0,0 +1,250 @@ +"""PLAN 322-2 구조물 표 화면 — 검사 짝(화면 = 서버) · 반영 손질 · [저장] 길 · 명세 폼 숨김. + +- 거울: 검사 견본마다 TS `catalogProblems` = 파이썬 `catalog_errors`(글까지 같음). +- 1-3 규칙: 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 부품이 읽는 칸 새로 숨김 금지 · + 만든 뒤 고정(자리꼴 · 정본 · 군 글자 · 종류 지우기 · 선택지 값). +- 반영 손질: 처음 값이면 반영에서 지움 · 목록 + enabled(both 일 때만 참) · 형상칸 · 보임 · 부품 켬끔. +- 서버 [저장]: 반영 · 부품 · 알약 색 · enabled 를 받아 판 +1 · 표(`catalog()`)가 바로 따름 · 틀리면 422. +- 명세 폼 숨김: 칸 = 쓰는 옵션 키가 모두 숨김 · 상자 = 키 있는 칸이 모두 숨김(지금 정의로는 숨는 칸 없음). +TS 쪽 = `helper_322_2_check.cjs`(Node) · 마스터 정의는 읽기만(사본으로 고침). +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B05_Profile import B05_Profile_Structures_Schema as schema +from common_util.common_util_auth import verify_session +from common_util.common_util_structure_catalog import catalog +from M02_MasterTemplete.StructOptions.M02_SO_Check import catalog_errors +from M02_MasterTemplete.StructOptions.M02_SO_Router import read_router + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent +URL = "/api/m02/structure-definition" +ADMIN = {"role": "SYSTEM_ADMIN", "user_id": 1} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _real() -> dict: + return json.loads(Path(schema.DEFINITION_PATH).read_text(encoding="utf-8")) + + +def _type(doc: dict, type_id: str) -> dict: + return next(t for t in doc["types"] if t["type_id"] == type_id) + + +def _cases() -> dict[str, tuple[dict, dict | None]]: + real = _real() + cases: dict[str, tuple[dict, dict | None]] = {"real": (real, real)} + bad = copy.deepcopy(real) + bad["반영"] = { + "nope": {}, + "pipe": { + "모름": 1, + "목록": "x", + "횡단도": "zz", + "형상": 3, + "형상칸": {"form": " "}, + "3D": "none", + }, + } + bad["부품"] = {"형상.zz": False, "횡단도.span3": "no", "끔": False} + cases["shape_bad"] = (bad, real) + rule = copy.deepcopy(real) + rule["반영"] = {"open_ditch": {"횡단도": "anchor"}} + cases["rule218"] = (rule, real) + mismatch = copy.deepcopy(real) + mismatch["반영"] = {"retaining_wall": {"형상": "pipe_set"}, "berm": {"횡단도": "anchor"}} + cases["mismatch"] = (mismatch, real) + hide = copy.deepcopy(real) + hide["표시"]["pipe"]["revet_foundation"] = False # 관 세트가 읽는 기본값 없는 칸 새로 숨김 + hide["표시"]["retaining_wall"]["height_m"] = False # 기본값 있는 칸 = 숨겨도 됨(늘 기본값) + cases["hide_read"] = (hide, real) + cases["hide_no_before"] = (hide, None) + fixed = copy.deepcopy(real) + _type(fixed, "box_culvert")["placement"] = "interval" + _type(fixed, "revetment")["managed_by"] = None + _type(fixed, "mirror")["group"] = "F" + dia = next(o for o in _type(fixed, "pipe")["options"] if o["key"] == "pipe_diameter_mm") + dia["choices"] = [*dia["choices"], "2000"] + fixed["types"] = [t for t in fixed["types"] if t["type_id"] != "etc"] + cases["fixed"] = (fixed, real) + off = copy.deepcopy(real) + off["부품"] = {"형상.wall": False} + cases["parts_off"] = (off, real) + drain = copy.deepcopy(real) + drain["부품"] = {"형상.underdrain": False} + cases["underdrain_off"] = (drain, real) + return cases + + +@pytest.fixture(scope="module") +def ts() -> dict: + payload = {"cases": _cases(), "definition": _real()} + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_2_check.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def test_check_mirror_screen_equals_server(ts: dict) -> None: + for name, (doc, before) in _cases().items(): + assert catalog_errors(doc, before) == ts["cases"][name], name + + +def test_check_rules(ts: dict) -> None: + got = ts["cases"] + assert got["real"] == [] + assert got["shape_bad"] == [ + "반영 「nope」 — 없는 종류이거나 묶음 아님", + "반영 「pipe.모름」 — 모르는 칸", + "반영 「pipe.목록」 — both · b06 · hidden 중 하나", + "반영 「pipe.횡단도」 — 없는 부품 「zz」", + "반영 「pipe.형상」 — 없는 부품 「3」", + "반영 「pipe.형상칸」 — form 글 · weep 참거짓", + "부품 「형상.zz」 — 없는 부품", + "부품 「횡단도.span3」 — 참거짓", + "부품 「끔」 — 없는 부품", + ] + assert got["rule218"] == ["「개거(겉도랑)」 형상이 「없음」이면 횡단도도 「안 만듦」"] + assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물에 안 맞음"] + assert got["hide_read"] == [ + "「배관」 형상 「배관 세트」 이 쓰는 칸 「흙막이 기초」 — 숨길 수 없음" + ] + assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음 + assert got["fixed"] == [ + # 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음 + "「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음", + "「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음", + "「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물에 안 맞음", + "「배관」 「관경」 선택지 값 — 고칠 수 없음(이름만 고침)", + "「BOX암거」 자리꼴 — 만든 뒤 고정", + "「기슭막이」 정본 — 만든 뒤 고정", + "「반사경」 군 글자 — 만든 뒤 고정", + "종류 「기타」 — 지울 수 없음", + ] + # 형상 부품을 끄면 쓰는 종류 형상 none — 부품 이름 + 쓰는 종류 전부 한 줄(323-9) + walls = "옹벽, 돌쌓기(찰), 돌쌓기(메), 흙막이, 큰돌쌓기".split(", ") + assert got["parts_off"] == [ + f"부품 「벽」 끔 — 이 부품을 쓰는 종류({', '.join(f'C 사면안정 · {w}' for w in walls)})의 " + "횡단도를 「안 만듦」으로 바꿔야 저장" + ] + assert got["underdrain_off"] == [ + "부품 「맹암거」 끔 — 이 부품을 쓰는 종류(A 횡단배수 · 맹암거)의 횡단도를 「안 만듦」으로 바꿔야 저장" + ] + + +def test_reflect_edits(ts: dict) -> None: + r = ts["reflect"] + assert r["wildlife"] == [None, True] # b06 → both = enabled 참이 곧 처음 값 짝(반영 안 남음) + assert r["wall"] == [None, False] # both → b06 = enabled 거짓 + assert r["openDitch"] is None # 처음 값(none)과 같음 — 반영 안 남음 + assert r["exposed"] == [{"횡단도": "anchor"}, True] + assert r["weepOn"] == {"형상칸": {"weep": True}} and r["weepOff"] is None + assert r["shown"] == {"pipe_kind": False, "inlet_type": False} # 정의 옵션만 + assert r["readPipe"] == ["inlet_basin_height_m", "revet_foundation"] # 기본값 없는 읽는 칸 + assert r["wallUsers"] == [ + "retaining_wall", + "masonry_wet", + "masonry_dry", + "soil_guard", + "boulder_masonry", + ] + assert r["reset"] == [None, None, True] + + +def test_revert_means_nothing_to_save(ts: dict) -> None: + assert ts["revert"] == [True, False] # 바꾸면 저장할 것 · 되돌리면 없음 + + +def test_save_writes_list_and_enabled_together(ts: dict) -> None: + sync = ts["sync"] + assert sync["same"] is True + rows = catalog() + assert set(sync["enabled"]) == {t for t, row in rows.items() if row["list"] == "both"} + assert len(sync["enabled"]) == 23 # B05 목록 23(브레인 화면 확인) + assert ts["partOff"] == [{"형상.wall": False}, "none"] and ts["partOn"] == {} + + +def test_spec_form_hidden_rule(ts: dict) -> None: + spec = ts["spec"] + assert spec["pipe"]["hiddenItems"] == ["pipe.diameter"] + assert spec["box_culvert"]["allBoxes"] == spec["box_culvert"]["keyedBoxes"] + assert all(s["noneHidden"] == 0 for s in spec.values()) + assert spec["etc"]["keyedBoxes"] == [] # 선택 상자 인스턴스 키는 옵션 밖 — 숨김 대상 아님 + + +@pytest.fixture +def copy_def(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "구조물정의.json" + path.write_text(Path(schema.DEFINITION_PATH).read_text("utf-8"), "utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + return path + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(read_router) + app.dependency_overrides[verify_session] = lambda: ADMIN + return TestClient(app) + + +def test_server_save_reflect_parts_color_enabled(copy_def: Path) -> None: + client = _client() + doc = client.get(URL).json() + sent = copy.deepcopy(doc) + sent["반영"] = {"open_ditch": {"횡단도": "anchor", "형상": "underdrain", "목록": "b06"}} + sent["부품"] = {"3D.chute": False} + _type(sent, "mirror")["style"]["color"] = "#123456" + _type(sent, "masonry_wet")["enabled"] = False # 목록 hidden ⇒ enabled 거짓(화면이 함께 씀) + res = client.put(URL, json={"판": doc["판"], "정의": sent}) + assert res.status_code == 200, res.text + got = res.json() + assert got["판"] == doc["판"] + 1 + assert got["반영"] == sent["반영"] and got["부품"] == sent["부품"] + rows = catalog() # 파일 시각으로 바로 새 표 + assert (rows["open_ditch"]["station"], rows["open_ditch"]["list"]) == ("anchor", "b06") + assert rows["ford_pavement"]["model3d"] == "none" + assert rows["mirror"]["color"] == "#123456" + assert rows["masonry_wet"]["list"] == "hidden" + + +@pytest.mark.parametrize( + "change", + [ + lambda d: d.update(반영={"open_ditch": {"횡단도": "anchor"}}), # 218 + lambda d: d["표시"].setdefault("pipe", {}).update(revet_foundation=False), # 못 숨김 칸 + lambda d: next(o for o in _type(d, "pipe")["options"] if o["key"] == "pipe_kind")[ + "choices" + ].append("새관"), # 선택지 값 + lambda d: _type(d, "berm").update(placement="point"), # 자리꼴 고정 + ], +) +def test_server_rejects_rule_breaks(copy_def: Path, change) -> None: + client = _client() + doc = client.get(URL).json() + sent = copy.deepcopy(doc) + change(sent) + res = client.put(URL, json={"판": doc["판"], "정의": sent}) + assert res.status_code == 422, res.text + assert json.loads(copy_def.read_text("utf-8"))["판"] == doc["판"] diff --git a/resources/tester/test_322_3_flow.py b/resources/tester/test_322_3_flow.py new file mode 100644 index 000000000..7c85d1c5d --- /dev/null +++ b/resources/tester/test_322_3_flow.py @@ -0,0 +1,113 @@ +"""PLAN 322-3 카드 목록 흐름 일곱 — 초안 측점 맞춤(`withDraftStations`)이 측점 계획 한 벌을 따름. + +놓기 · 일반 측점에 놓기(219 흠 — No.N+0 자리 새 구조물 = 이름 칩 · 관 그림) · 옮기기 · 같은 군 바꿈 · +정본 넘는 바꿈(관 → 맹암거) · 지우기 · 저장 뒤 다시 열기(빼 둔 옛 자리 돌아옴). +각 줄 = [측점, 카드 이름, 미리 뜬 사본, 관 계열 그림 열쇠]. TS 쪽 = `helper_322_3_flow.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def flows() -> dict: + out = subprocess.run( + ["node", str(HERE / "helper_322_3_flow.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _marked(rows: list) -> list: + return [row for row in rows if row[1] or row[2] or row[3]] + + +def test_place(flows: dict) -> None: + assert _marked(flows["place"]) == [ + [93, "옹벽", True, []], + [101, "옹벽", True, []], + [113, "옹벽", True, []], + ] + + +def test_place_on_general_station_gets_name_and_shape(flows: dict) -> None: + # 219 흠 — 일반 측점 자리(100 · 120)에 새로 놓은 맹암거 · 관 = 그 자리 사본에 이름 칩 · 관 그림 + assert _marked(flows["gridPlace"]) == [ + [100, "맹암거", True, []], + [120, "파형강관 D800", True, ["culvert"]], + ] + assert len(flows["gridPlace"]) == 11 # 측점 수 그대로(사본으로 갈음) + + +def test_move_hides_old_and_adds_new(flows: dict) -> None: + assert _marked(flows["move"]) == [[75, "파형강관 D1000", True, ["culvert"]]] + assert all(row[0] != 61 for row in flows["move"]) + + +def test_same_group_switch_renames(flows: dict) -> None: + assert _marked(flows["sameGroup"]) == [ + [93, "흙막이", True, []], + [101, "흙막이", True, []], + [113, "흙막이", True, []], + ] + + +def test_cross_owner_switch_drops_pipe_drawing(flows: dict) -> None: + assert _marked(flows["crossOwner"]) == [[101, "맹암거", True, []]] + + +def test_pick_follows_moved_pipe(flows: dict) -> None: + moved, other = flows["follow"] + assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-s1") # 세션 id 그대로(323-5) + assert (other["chainageM"], other["structureId"]) == (101, "w1") # 구조물 고름은 그대로 + + +def test_remove_and_reopen(flows: dict) -> None: + assert _marked(flows["remove"]) == [] + assert [row[0] for row in flows["remove"]] == list(range(0, 201, 20)) + assert _marked(flows["reopen"]) == [ + [93, "옹벽", False, []], + [101, "옹벽", False, []], + [113, "옹벽", False, []], + ] + + +# ── 옛 소수 관(검증_sub1 견본) — B06 을 바로 열어도 B05 와 같은 정수 m 초안(요구 167 · `snapPlan`) ── +SNAPPED = [85, 173, 258, 352, 440, 534, 620, 720, 804, 900, 983] + + +def test_old_decimal_pipes_snap_like_b05(flows: dict) -> None: + assert flows["snapTo"] == [SNAPPED, 0] + + +def test_saved_integer_stations_keep_cards(flows: dict) -> None: + # 서버가 정수 자리로 심은 저장분 = 계획과 같음 — 저장 카드 그대로 · 새 미리 뜬 카드 0 + rows = flows["snapSavedInt"] + assert [row[0] for row in _marked(rows)] == SNAPPED + assert not any(row[2] for row in rows) + assert len(rows) == 58 + + +def test_saved_decimal_stations_follow_plan(flows: dict) -> None: + # 소수 자리 저장분 = B05 를 먼저 연 것과 같음 — 소수 카드는 빼 두고 정수 자리(격자 자리는 사본) + rows = flows["snapSavedDec"] + assert [row[0] for row in _marked(rows)] == SNAPPED + assert all(row[2] for row in _marked(rows)) + assert all(float(row[0]).is_integer() for row in rows) + assert len(rows) == 58 diff --git a/resources/tester/test_322_3_plan_mirror.py b/resources/tester/test_322_3_plan_mirror.py new file mode 100644 index 000000000..ae2e51d0b --- /dev/null +++ b/resources/tester/test_322_3_plan_mirror.py @@ -0,0 +1,178 @@ +"""PLAN 322-3 측점 계획 한 벌 — 파이썬 `plan_stations` = TS `planStations`(거울) · 서버 공급자가 같은 목록. + +- 측점 = 표 횡단도(anchor 기준 · span3 시작 · 기준 · 끝 · none 없음) · 카드 이름 = 표 카드 이름. +- 같은 m(0.005) 는 하나 — 이름 = 관이 먼저 · 아니면 앞선 것 · 덮는 구조물 id 모두. +- 빈칸 경고 = 형상 부품 필수 칸 중 값이 빈 것(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이). +- 서버 `resolve_extra_stations` = 계획의 (측점, 이름) — 겹친 자리는 한 번(옛 「마지막 이름」 규칙 걷음). +TS 쪽 = `helper_322_3_plan.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Engine_Sections import resolve_extra_stations +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_station import plan_stations +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _s(type_id: str, sid: str, **at) -> dict: + placement = "interval" if "start_m" in at else "point" + return { + "structure_id": sid, + "type_id": type_id, + "placement": placement, + "options": at.pop("options", {}), + **at, + } + + +CASES = { + "mixed": { + "pipes": [ + {"chainage_m": 95.0, "source": "user"}, + {"chainage_m": 160.0, "source": "user", "facility": "box_culvert"}, + {"chainage_m": 230.0, "source": "user", "facility": "ford_pavement"}, + { + "chainage_m": 400.0, + "source": "user", + "facility": "revetment", + "start_m": 395.0, + "end_m": 410.0, + "options": {"height_m": 2}, + }, + ], + "structures": [ + _s("cross_drain_exposed", "s1", chainage_m=120.0), + _s( + "retaining_wall", + "s2", + start_m=500.0, + chainage_m=510.0, + end_m=530.0, + options={"height_m": 3}, + ), + _s("berm", "s3", start_m=530.0, chainage_m=545.0, end_m=560.0), + _s("erosion_check", "s4", chainage_m=600.0), + _s("pavement_concrete", "s5", start_m=650.0, chainage_m=650.0, end_m=700.0), + _s("cross_underdrain", "s6", chainage_m=95.003), + ], + }, + "pipe_kinds": { + "pipes": [ + {"chainage_m": 10.0, "options": {"pipe_kind": "흄관", "pipe_diameter_mm": "800"}}, + {"chainage_m": 20.0, "options": {"pipe_diameter_mm": ""}}, + {"chainage_m": 30.0, "facility": "ford_bridge"}, + ], + "structures": [], + }, + "empty": {"pipes": [], "structures": []}, +} + + +@pytest.fixture(scope="module") +def ts() -> dict: + definition = load_definition() + payload = { + "definition": definition, + "types": with_visible().model_dump()["types"], + "cases": CASES, + } + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_322_3_plan.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _py(case: dict) -> list[dict]: + pipes = parse_pipe_points(case["pipes"]) + structures = [StructureInstance.model_validate(s) for s in case["structures"]] + return plan_stations(pipes, structures) + + +def test_mirror_python_equals_ts(ts: dict) -> None: + for name, case in CASES.items(): + assert _py(case) == ts[name], name + + +def test_plan_rules(ts: dict) -> None: + mixed = ts["mixed"] + assert [round(s["chainage_m"], 3) for s in mixed] == [ + 95.0, + 160.0, + 230.0, + 395.0, + 400.0, + 410.0, + 500.0, + 510.0, + 530.0, + 545.0, + 560.0, + ] + first = mixed[0] # 관 95 + 맹암거 95.003 = 한 측점 · 이름 = 관 · 빈칸 = 관경 + 맹암거 높이 + assert first["owners"] == ["pipe-95.00", "s6"] + assert first["title"] == "파형강관 D1000" + assert first["blank"] == ["pipe_diameter_mm", "culvert_height_m"] + joint = next(s for s in mixed if s["chainage_m"] == 530.0) # 옹벽 끝 = 소단 시작 + assert joint["owners"] == ["s2", "s3"] and joint["title"] == "옹벽" and joint["role"] == "end" + assert all(s["title"] != "노출형 횡단수로" for s in mixed) # ★1 횡단도 none + kinds = ts["pipe_kinds"] + assert [s["title"] for s in kinds] == ["흄관 D800", "파형강관 D1000", "세월교"] + assert kinds[1]["blank"] == ["pipe_diameter_mm"] and kinds[0]["blank"] == [] + assert ts["empty"] == [] + + +def test_server_supply_is_plan(tmp_path: Path) -> None: + case = CASES["mixed"] + route = tmp_path / "B05_Profile" / "route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 1, "structures": case["structures"]}, ensure_ascii=False), + encoding="utf-8", + ) + extras = resolve_extra_stations(tmp_path, parse_pipe_points(case["pipes"])) + plan = _py(case) + assert list(extras) == [(s["chainage_m"], s["title"]) for s in plan] + assert len({round(c, 3) for c, _ in extras}) == len(extras) # 같은 자리 한 번 + + +def test_stale_marks_compare_position_and_name(tmp_path: Path, monkeypatch) -> None: + """서버 저장 낡음 = 자리 + 이름(종류 · 관 공식 · 정의 판 이름) — 같은 자리라도 이름이 다르면 맞춤.""" + from B06_Section import B06_Section_Router_Stations as stations + + folder = tmp_path / "B06_Section" / "longitudinal" + folder.mkdir(parents=True) + rows = [ + {"chainage_m": 10.0, "kind": "irregular", "structure": "파형강관 D800"}, + {"chainage_m": 20.0, "kind": "regular"}, + {"chainage_m": 30.0, "kind": "irregular", "structure": "옹벽"}, + ] + (folder / "l.json").write_text(json.dumps({"stations": rows}), encoding="utf-8") + planted = [(10.0, "파형강관 D1000"), (30.0, "옹벽")] + monkeypatch.setattr(stations, "_planted", lambda *_: planted) + assert stations._stale_marks(tmp_path, "") == 2 # 10 m 옛 이름 · 새 이름 + planted[0] = (10.0, "파형강관 D800") + assert stations._stale_marks(tmp_path, "") == 0 diff --git a/resources/tester/test_322_3_saved_snap.py b/resources/tester/test_322_3_saved_snap.py new file mode 100644 index 000000000..3ada93205 --- /dev/null +++ b/resources/tester/test_322_3_saved_snap.py @@ -0,0 +1,25 @@ +"""PLAN 322-3 고침 — 노선이 바뀌어 관을 좌표로 옮길 때(투영 이월) 끝 = 선 길이. + +B05 계획선 꼭짓점은 누가거리 0 으로 와서 끝이 0 으로 잡혀 관이 전부 0 m 로 가던 흠. +""" + +from __future__ import annotations + +import json + +from B05_Profile.B05_Profile_Engine_Sections import _load_pipe_points + +LINE = [[0.0, 0.0, 100.0], [300.0, 0.0, 100.0]] + + +def test_projected_pipes_snap_to_whole_metres_not_zero(tmp_path) -> None: + folder = tmp_path / "B04_PreProcess" / "drainage" / "edits" + folder.mkdir(parents=True) + points = [ + {"chainage_m": 85.05, "source": "user", "x": 90.05, "y": 0.3}, + {"chainage_m": 173.09, "source": "user", "x": 178.4, "y": 0.2}, + ] + (folder / "pipe_points.json").write_text( + json.dumps({"route_signature": "옛노선", "points": points}), encoding="utf-8" + ) + assert [pipe.chainage_m for pipe in _load_pipe_points(tmp_path, LINE)] == [90.0, 178.0] diff --git a/resources/tester/test_323_4_attach_mirror.py b/resources/tester/test_323_4_attach_mirror.py new file mode 100644 index 000000000..a74a5ed55 --- /dev/null +++ b/resources/tester/test_323_4_attach_mirror.py @@ -0,0 +1,178 @@ +"""PLAN 323-4 형상 얹기 한 벌 — 파이썬 `attach_shapes` = TS `attachShapes`(거울) · 저장 전 그림 = 저장 뒤 그림. + +- 42종 견본(관 지점 · 구조물 · 정의 기본값) + 빈칸 견본 → 측점마다 그림 자료(관 세트 · 벽 · 포장 · 맹암거) · + 빈칸 경고 `blank`(「종류 칸」 · 측점 계획 자리)가 서버 = 화면. +- 옛 열쇠는 걷고 다시(물넘이포장을 옮기면 옛 자리 파임이 빠짐 · 걸친 측점에 새로). +- 3D — C군 벽은 덮는 측점 중 기준 측점에서만 시작 ~ 끝 스윕 · 지문 = 그림 자료 통째(손 해시가 빠뜨린 칸도). +TS 쪽 = `helper_323_4_attach.cjs`(Node). +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition +from B06_Section.B06_Section_Engine_Attach import attach_shapes +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_catalog import catalog +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +TYPES = with_visible().model_dump()["types"] + + +def _defaults(type_id: str) -> dict: + """정의 기본값(빈 값 뺌) — 그림이 서는 견본.""" + options = next(t for t in TYPES if t["type_id"] == type_id)["options"] + return {o["key"]: o["default"] for o in options if o["default"] not in (None, "")} + + +def _samples() -> tuple[list[dict], list[dict]]: + """42종 하나씩(30 m 간격 · 구간형 = 기준 ± 5) + 빈칸 셋(관경 · 옹벽 높이 · 맹암거 높이).""" + pipes: list[dict] = [] + structures: list[dict] = [] + rows = sorted(catalog().values(), key=lambda row: row["typeId"]) + for index, row in enumerate(rows): + at = 15.0 + 30.0 * index + options = _defaults(row["typeId"]) + if row["owner"] == "pipe_points": + pipes.append({"chainage_m": at, "facility": row["typeId"], "options": options}) + continue + span = {"start_m": at - 5, "end_m": at + 5} if row["placement"] == "interval" else {} + structures.append( + { + "structure_id": f"s{index}", + "type_id": row["typeId"], + "placement": row["placement"], + "chainage_m": at, + "options": options, + **span, + } + ) + end = 15.0 + 30.0 * len(rows) + pipes.append({"chainage_m": end, "facility": "pipe", "options": {"pipe_diameter_mm": ""}}) + structures.append( + { + "structure_id": "blank-wall", + "type_id": "retaining_wall", + "placement": "interval", + "start_m": end + 25, + "chainage_m": end + 30, + "end_m": end + 35, + "options": {}, + } + ) + structures.append( + { + "structure_id": "blank-drain", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": end + 60, + "options": {}, + } + ) + return pipes, structures + + +def _sections(until: float) -> list[dict]: + """5 m 마다 측점(모든 기준 · 시작 · 끝 자리) — 옛 열쇠를 미리 얹어 걷힘도 봄.""" + out = [] + for step in range(int(until // 5) + 2): + section = {"chainage_m": float(step * 5)} + if step == 1: + section["ford_pavement"] = {"type": "ford_pavement", "chainage_m": 5.0} # 옛 자리 + out.append(section) + return out + + +PIPES, STRUCTURES = _samples() +UNTIL = 15.0 + 30.0 * len(catalog()) + 60 +CASES = { + "all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES}, + "empty": {"sections": _sections(30), "pipes": [], "structures": []}, +} + + +@pytest.fixture(scope="module") +def ts() -> dict: + payload = {"definition": load_definition(), "types": TYPES, "cases": CASES} + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_323_4_attach.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _py(case: dict) -> list[dict]: + sections = copy.deepcopy(case["sections"]) + pipes = parse_pipe_points(case["pipes"]) + structures = [StructureInstance.model_validate(s) for s in case["structures"]] + attach_shapes(sections, pipes, structures) + return json.loads(json.dumps(sections)) + + +def test_mirror_python_equals_ts(ts: dict) -> None: + for name, case in CASES.items(): + py, js = _py(case), ts["cases"][name] + assert len(py) == len(js), name + for left, right in zip(py, js): + assert left == right, (name, left["chainage_m"]) + + +def test_every_shape_lands(ts: dict) -> None: + keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"} + assert { + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", + } <= keys + old = ts["cases"]["all"][1] # 옛 물넘이 자리(5 m) — 걷힘 + assert "ford_pavement" not in old + assert all(len(section) == 1 for section in ts["cases"]["empty"]) + + +def test_blank_chips(ts: dict) -> None: + end = 15.0 + 30.0 * len(catalog()) + by_at = {s["chainage_m"]: s.get("blank") for s in ts["cases"]["all"]} + assert by_at[end] == ["배관 관경"] + assert by_at[end + 25] == by_at[end + 30] == by_at[end + 35] == ["옹벽 높이"] + assert by_at[end + 60] == ["맹암거 암거 높이"] + assert by_at[end + 5] is None # 계획 자리만 + + +def test_wall_sweeps_once(ts: dict) -> None: + assert ts["sweep"] == [None, None, {"beforeM": 8, "afterM": 12}, None] + + +def test_fingerprint_covers_drawing(ts: dict) -> None: + assert ts["fingerprint"] == { + "same": True, + "foundation": True, + "pavement": True, + "underdrain": True, + "adjust": True, + "plain": True, + } diff --git a/resources/tester/test_323_5_pick.py b/resources/tester/test_323_5_pick.py new file mode 100644 index 000000000..2b373539e --- /dev/null +++ b/resources/tester/test_323_5_pick.py @@ -0,0 +1,67 @@ +"""PLAN 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(`pickLights`). + +- 관 id = 자리마다 차례(`pipe-s1` …) · 같은 자리(PIPE_MATCH_M 안)면 같은 id · 프로젝트 없으면 옛 자리 이름. +- 관을 옮기면 id 가 새 자리로 · 그 관을 고른 고름은 자리만 새로(부재키 그대로) · 다른 고름은 그대로. +- 강조 = id 둘 다 있으면 id · 아니면 같은 자리 · 고른 부분이 있으면 그 부분만 — 카드 형상 · 3D 같은 함수. +- 카드 통째 형상 칠하기는 부분(조정창 부재) · 관 계열 · 포장층을 안 건드림(307). +TS 쪽 = `helper_323_5_pick.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_5_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def test_session_ids(out: dict) -> None: + assert out["ids"] == ["pipe-s1", "pipe-s1", "pipe-s2", 85.05] + assert out["legacy"] == ["pipe-85.00", 85, None] + + +def test_move_keeps_id_and_pick(out: dict) -> None: + picked, at90, moved_to = out["moved"] + assert picked == {"chainageM": 90, "structureId": "pipe-s1", "partKey": "inlet"} + assert (at90, moved_to) == ("pipe-s1", 90) + other, same = out["other"] + assert other["structureId"] == "w1" and other["chainageM"] == 300 and same is True + + +def test_one_highlight_rule(out: dict) -> None: + assert out["lights"] == { + "idOverPlace": True, + "idDiffers": False, + "placeNear": True, + "placeFar": False, + "partOnly": False, + "partSame": True, + "partNoTag": False, + "wholePick": True, + "none": False, + } + + +def test_card_paint_whole_shapes_only(out: dict) -> None: + assert out["paint"] == [[True, False, False, False], [False, False, False, False]] diff --git a/resources/tester/test_323_6_change.py b/resources/tester/test_323_6_change.py new file mode 100644 index 000000000..e15175cc9 --- /dev/null +++ b/resources/tester/test_323_6_change.py @@ -0,0 +1,87 @@ +"""PLAN 323-6 바꿈 한 길 — 바뀜 신호 하나 · 종류 바꿈 표 규칙 · 그리기가 조정값을 안 만듦 · 옛 되얹기 걷음. + +- 구조물 초안도 설계 가게(`useDesignStore`)로 씀 = 관 초안과 같은 신호 · 칸은 그대로(`structures`) · + 횡단 보기가 두 초안을 구독해 카드 · 그림을 다시(옛 되부름 `onStructuresChanged` 갈음). +- 종류 바꿈 = 정본 같고 구조물은 자리꼴도 같아야 제자리(`swapsInPlace`) · 아니면 지우고 같은 기준 측점에 새로 + (흐름 = test_319_kind_swap). +- 카드 그리기의 적용값 되받기는 사용자가 조작한 세션이 있을 때만(자동 배치를 조정값으로 안 굳힘). +TS 쪽 = `helper_323_6_change.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_6_change.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def _src(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +def test_structure_draft_signals_through_store(out: dict) -> None: + assert out["heard"] == [1, None, 1] and out["pipesHeard"] == 0 + assert out["read"] == [{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 10}] + assert out["key"] is True # 칸 그대로 + cross = _src("A00_Common", "design", "design_view_cross.ts") + assert "watchDrafts(projectId," in cross and "onStructuresChanged" not in cross + panel = _src("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "onStructuresChanged" not in panel # 옛 되부름 걷음 + + +def test_change_signal_filters_option_only_pipe_edits(out: dict) -> None: + # 관 놓기 · 옵션만 · 옮기기 · 종류 바꿈 · 구조물 · 끊은 뒤 = 1 · 0 · 1 · 1 · 1 · 0 + assert out["watch"] == 4 + + +def test_kind_swap_rule_from_table(out: dict) -> None: + assert dict(out["swaps"]) == { + "mirror>curb": True, + "mirror>guardrail": False, + "refuge>work_yard": True, + "pipe>box_culvert": True, + "pipe>revetment": True, + "cross_underdrain>pipe": False, + } + + +def test_drawing_does_not_create_adjusts() -> None: + controls = _src("B06_Section", "B06_Section_UI_Page_Station_Controls.ts") + body = controls.split("syncApplied: (chainageM, role, applied) => {", 1)[1].split("},", 1)[0] + assert "if (!stored || sameAdjust(stored, applied)) return;" in body + + +def test_add_names_blocking_field() -> None: + # [추가] 무반응 → 막힌 칸 이름 알림(323-6 고침 · 실시간 반영은 알림 없음) + commit = _src("B05_Profile", "B05_Profile_UI_Structures_Panel_Commit.ts") + assert "if (!live) showToast(" in commit + assert "if (missing && !live) return stop(missing.input, labelOf(missing.key));" in commit + assert 'return stop(input, labelOf("length_m"));' in commit + + +def test_old_replay_removed() -> None: + assert "replayDraftPipeOptions" not in _src( + "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts" + ) diff --git a/resources/tester/test_323_7_new_type.py b/resources/tester/test_323_7_new_type.py new file mode 100644 index 000000000..3e01fb740 --- /dev/null +++ b/resources/tester/test_323_7_new_type.py @@ -0,0 +1,106 @@ +"""PLAN 323-7 종류 더하기 — 본보기 복사 한 종류가 일곱 갈래(목록 · 횡단도 · 카드이름 · 형상 · 형상칸 · 3D · 자리꼴) +를 본보기 그대로 그림 · 손코딩 동작 · 명세는 본보기 키로(`baseType`) · 검사는 화면 = 서버. + +마스터는 읽기만 — 사본(헬퍼가 더한 정의)을 임시 파일로 두고 서버 쪽 읽기를 그리로 돌림. +TS 쪽 = `helper_323_7_new_type.cjs`(Node). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile import B05_Profile_Structures_Schema as schema +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from B06_Section.B06_Section_Engine_Structures_Wall import wall_specs_from +from common_util import common_util_structure_catalog as catalog +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def ts() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_7_new_type.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +@pytest.fixture() +def copied(ts: dict, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + path = tmp_path / "구조물정의.json" + path.write_text(json.dumps(ts["def"], ensure_ascii=False), encoding="utf-8") + monkeypatch.setattr(schema, "DEFINITION_PATH", str(path)) + monkeypatch.setattr(catalog, "DEFINITION_PATH", str(path), raising=False) + return ts["def"] + + +def test_only_structure_originals_are_templates(ts: dict) -> None: + assert "pipe" not in ts["templates"] and "retaining_wall" in ts["templates"] + assert ts["pipeRefused"] is None + + +def test_seven_branches_follow_template(ts: dict) -> None: + for name, (source, copy) in ts["rows"].items(): + assert copy == source, name + assert ts["base"] == ["retaining_wall", "retaining_wall"] + assert ts["names"] == ["옹벽 2", "콘크리트 포장 2"] + + +def test_new_type_draws_like_template(ts: dict) -> None: + wall = ts["keys"][0] + assert ts["plan"] == [[40, "옹벽 2"], [50, "옹벽 2"], [60, "옹벽 2"]] + assert ts["attached"] == [[wall, "옹벽 2"]] * 3 + assert ts["tier"] == [ + {"form": "콘크리트", "height_m": 1} + ] # 옹벽 추가 단 = 콘크리트(본보기 동작) + assert ts["spec"] is True # 포장 명세 = 본보기 명세 + + +def test_check_screen_equals_server(ts: dict, copied: dict) -> None: + master = json.loads(Path(definition.schema.DEFINITION_PATH).read_text(encoding="utf-8")) + assert ts["problems"] == [] + real = json.loads( + (ROOT / "resources" / "master_template" / "구조물정의.json").read_text("utf-8") + ) + assert definition.check(copied, real) == [] + assert ts["badBase"] is True and ts["chain"] is True + bad = json.loads(json.dumps(copied)) + bad["종류"][ts["keys"][0]]["본보기"] = "soil_guard" + assert any("본보기" in e for e in definition.check(bad, copied)) + assert master["types"] # 사본을 읽음 + + +def test_server_reads_copy_like_template(ts: dict, copied: dict) -> None: + wall = ts["keys"][0] + rows = catalog.resolve_catalog(copied) + keep = ("list", "station", "title", "shape", "shapeOpts", "model3d", "placement", "owner") + assert {k: rows[wall][k] for k in keep} == {k: rows["retaining_wall"][k] for k in keep} + assert rows[wall]["base"] == "retaining_wall" + item = StructureInstance.model_validate( + { + "structure_id": "n1", + "type_id": wall, + "placement": "interval", + "start_m": 40, + "chainage_m": 50, + "end_m": 60, + "options": {"height_m": 2, "tier_1_height": 1}, + } + ) + (spec,) = wall_specs_from([item]) + assert spec["name"] == "옹벽 2" and spec["extras"][0]["form"] == "콘크리트" diff --git a/resources/tester/test_323_9_definition_stamp.py b/resources/tester/test_323_9_definition_stamp.py new file mode 100644 index 000000000..fcb316a9e --- /dev/null +++ b/resources/tester/test_323_9_definition_stamp.py @@ -0,0 +1,53 @@ +"""PLAN 323-9 ② — 정의 판 알림 되풀이: 구조물을 안 고치고 [저장]하면 정본이 안 나가 옛 판 그대로였음. + +정본이 옛 정의 판이면 [저장]이 고침 없어도 정본을 다시 써 지금 판 도장 → 다시 열면 알림 없음. +B06 기본 창구(`flushPendingStructures`) · B05 보기 창구(`saveStructuresIfDirty`) 같은 길. +Node 헬퍼 = `helper_323_9_definition_stamp.cjs`(가짜 서버 · 진짜 API 모듈). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_9_definition_stamp.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def test_old_version_save_without_edits_stamps_current(out: dict) -> None: + assert out["notice"] == "구조물 정의가 판 21 으로 바뀜 — 저장하면 반영" + assert out["staleOnOpen"] is True + assert out["afterSave"] == {"version": 21, "puts": [1]} + assert out["structures"][0]["structure_id"] == "s1" # 정본 그대로 + + +def test_reopen_has_no_notice_and_no_more_puts(out: dict) -> None: + assert out["again"] is None and out["staleAfter"] is False + assert out["putsAfterSecond"] == 1 + + +def test_b05_view_save_uses_same_stamp() -> None: + page = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Structures.ts").read_text("utf-8") + body = page[page.index("async function saveStructuresIfDirty") :] + assert "readPending() === null && !definitionStale(deps.projectId)" in body[:400] + assert "persistStructures(readPending() ?? ownStructures)" in body[:900] diff --git a/resources/tester/test_323_9_save_redraw.py b/resources/tester/test_323_9_save_redraw.py new file mode 100644 index 000000000..a9d92f71d --- /dev/null +++ b/resources/tester/test_323_9_save_redraw.py @@ -0,0 +1,60 @@ +"""PLAN 323-9 ① — B06 [저장] 뒤 카드가 저장 전 서버 상태로 돌아가던 흠. + +저장 도중 바뀜 신호(초안이 정본으로 나가며 비워짐)가 낡은 상세 캐시 · 빈 초안으로 다시 그렸고, 뒤처리는 +캐시만 비우고 다시 안 그렸음 → 저장 도중엔 안 그림 · 뒤처리가 서버 새 상세로 한 번 그림. +Node 헬퍼 = `helper_323_9_save_redraw.cjs`(진짜 저장 차례 · 횡단 뒤처리 · 상세 캐시). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_9_save_redraw.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +def test_after_save_cards_show_server_new_detail(out: dict) -> None: + assert out["ok"] is True + assert out["before"] == "옛 자리 4+5" + assert out["after"] == "새 자리 4+8 · 47+0" + + +def test_no_redraw_from_stale_cache_while_saving(out: dict) -> None: + assert out["savingSeen"] == [True, False] and out["savingIdle"] is False + # 저장 도중 신호는 건너뜀 · 뒤처리가 새로 한 번 받음(오버레이 안) + assert out["calls"] == [ + "overlay:on", + "refresh:skip", + "cross:save", + "fetch:새 자리 4+8 · 47+0", + "overlay:off", + ] + + +def test_cross_view_wires_guard_and_redraw() -> None: + view = (ROOT / "A00_Common" / "design" / "design_view_cross.ts").read_text("utf-8") + body = view[view.index("async function refreshDetailForStructures") :] + body = body[: body.index("loadSectionDetail")] + assert "++structuresRefreshSeq" in body and "savingDesign(projectId)" in body + assert "redraw: () => (host.visible() ? refreshDetailForStructures()" in view diff --git a/resources/tester/test_323_9_twin_cards_3d.py b/resources/tester/test_323_9_twin_cards_3d.py new file mode 100644 index 000000000..b1950230c --- /dev/null +++ b/resources/tester/test_323_9_twin_cards_3d.py @@ -0,0 +1,45 @@ +"""PLAN 323-9 ④-② — 같은 시설을 그리는 카드 둘(같은 m · 노선 끝 EP 꼴 0.01 m 차)이면 3D 몸체가 둘 서던 흠. + +3D 몸체는 시설(관 · 세월교 · BOX) 하나에 한 번 — 시설 자리에 가장 가까운 카드만 세움(`ownsBody`). +Node 헬퍼 = `helper_323_9_twin_cards_3d.cjs`(73-4 픽스처 · 진짜 `buildCorridorStructures`). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_9_twin_cards_3d.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +@pytest.mark.parametrize("at", ["173.087", "352.14"]) +def test_twin_cards_build_bodies_once(out: dict, at: str) -> None: + got = out[at] + assert got["one"] and got["twin"] == got["one"] + assert len(got["before"]) == 2 * len(got["one"]) # 고치기 전 = 두 번(시험이 변경을 잼) + # 노선 끝 EP 꼴 — 같은 시설 카드가 소수 차(0.010813 m) · 차례가 바뀌어도 시설 자리에 가까운 카드 하나 + assert got["epTwin"] == got["one"] and got["epFirst"] == got["one"] + assert len(got["epBefore"]) == 2 * len(got["one"]) + if at == "173.087": # 세월교 관 두 련 — 관 몸체도 한 번 + assert sum(k.startswith("pipe:") for k in got["twin"]) == 2 diff --git a/resources/tester/test_52_7_safety.py b/resources/tester/test_52_7_safety.py index 824382bef..5f6c26ef7 100644 --- a/resources/tester/test_52_7_safety.py +++ b/resources/tester/test_52_7_safety.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """안전부대용지(표 07) — PLAN 52-7. -정의(대피로 · 가드레일 컨테이너 · 점형 일곱) · 옛 대피 종류 읽기(`asRoad` · `pickable`) · -화면은 ORCA `ui_checks/52-7_안전부대용지.js`. +정의(대피로 · 가드레일 컨테이너 · 점형 일곱) · 옛 대피 종류 읽기(`asRoad`) · 고르기 목록 숨김 = +구조물 표 처음값(322-1 · 옛 `pickable`) · 화면은 ORCA `ui_checks/52-7_안전부대용지.js`. """ import json @@ -10,6 +10,7 @@ import os import subprocess from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from common_util.common_util_structure_catalog import load_parts from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition HERE = os.path.dirname(os.path.abspath(__file__)) @@ -77,8 +78,6 @@ const d = m.exports; process.stdout.write(JSON.stringify({ old: d.asRoad({ type_id: "turnaround", options: { width_m: 10 } }), other: d.asRoad({ type_id: "mirror", options: { facing: "좌" } }), - pick: ["refuge", "work_yard", "turnaround", "evac_road", "guardrail"].map( - (type_id) => d.pickable({ type_id })), })); """ @@ -98,4 +97,6 @@ def test_옛_대피_종류는_대피로_상세로_읽고_목록에서_뺌(): "options": {"detail": "차돌림곳", "width_m": 10}, } assert out["other"] == {"type_id": "mirror", "options": {"facing": "좌"}} - assert out["pick"] == [False, False, False, True, True] + start = load_parts()["처음값"] # 고르기 목록 = 구조물 표(322-1 · 옛 대피 셋 hidden) + kinds = ("refuge", "work_yard", "turnaround", "evac_road", "guardrail") + assert [start[t]["목록"] for t in kinds] == ["hidden"] * 3 + ["both"] * 2 diff --git a/resources/tester/test_b05_extra_stations_supplier.py b/resources/tester/test_b05_extra_stations_supplier.py index f6d078ee0..18b397cd6 100644 --- a/resources/tester/test_b05_extra_stations_supplier.py +++ b/resources/tester/test_b05_extra_stations_supplier.py @@ -82,9 +82,10 @@ def test_pipe_points_become_labelled_stations(tmp_path): assert resolve_extra_stations(tmp_path, parse_pipe_points(LIVE_POINTS)) == LIVE_LABELS -def test_pipe_without_options_falls_back_to_config_defaults(tmp_path): +def test_pipe_without_options_falls_back_to_definition_defaults(tmp_path): + """빈칸 = 정의 기본값(322-1 관 공식 한 벌 · 옛 설정값 D800 ↔ 화면 D1000 어긋남 없앰).""" extras = resolve_extra_stations(tmp_path, parse_pipe_points([{"chainage_m": 30.0}])) - assert extras == ((30.0, "파형강관 D800"),) + assert extras == ((30.0, "파형강관 D1000"),) def test_ford_pavement_plants_its_station(tmp_path): @@ -106,7 +107,8 @@ def test_group_a_point_and_revetment_facility_join(tmp_path): """ _write_structures( tmp_path, - [{"type_id": "cross_drain_exposed", "chainage_m": 60.0, "placement": "point"}], + # 측점 심는 A군 점형 = 맹암거(노출형은 322-1 ★1 로 횡단도 none) + [{"type_id": "cross_underdrain", "chainage_m": 60.0, "placement": "point"}], ) points = [ *LIVE_POINTS, @@ -119,7 +121,7 @@ def test_group_a_point_and_revetment_facility_join(tmp_path): }, ] extras = resolve_extra_stations(tmp_path, parse_pipe_points(points)) - assert (60.0, "노출형 횡단수로") in extras + assert (60.0, "맹암거") in extras assert [chainage for chainage, label in extras if label == "기슭막이"] == [95.0, 100.0, 115.0] assert [chainage for chainage, _label in extras] == [ 60.0, diff --git a/resources/tester/test_b05_facility_options_merge.py b/resources/tester/test_b05_facility_options_merge.py index c1a5b5c8b..27c2d3853 100644 --- a/resources/tester/test_b05_facility_options_merge.py +++ b/resources/tester/test_b05_facility_options_merge.py @@ -110,9 +110,18 @@ def test_폼이_적는_칸이_모두_목록에_있다(merged) -> None: """폼에 칸을 늘리고 목록에 안 넣으면 여기서 잡힘.""" source = FORM_TS.read_text(encoding="utf-8") body = source[source.index("readOptions() {") :] - branches = re.split(r'current === "(\w+)"', body)[1:] - assert branches[::2] == ["pipe", "box_culvert", "ford_pavement", "ford_bridge", "revetment"] - for facility, block in zip(branches[::2], branches[1::2]): + # 폼 갈래 = 구조물 표 형상(323-7 · 종류 키 대신) → 관 지점 종류 + branches = re.split(r'shapeOf\(current\) === "(\w+)"', body)[1:] + kinds = { + "pipe_set": "pipe", + "box": "box_culvert", + "ford_pavement": "ford_pavement", + "ford": "ford_bridge", + "hidden_revet": "revetment", + } + assert branches[::2] == list(kinds) + for shape, block in zip(branches[::2], branches[1::2]): + facility = kinds[shape] written = set(re.findall(r'putNumber\(options, "(\w+)"', block)) written |= set(re.findall(r"options\.(\w+) =", block)) missing = written - set(merged["keys"][facility]) diff --git a/resources/tester/test_b05_station_roundtrip.py b/resources/tester/test_b05_station_roundtrip.py index 0c82da4d2..2df3f71e0 100644 --- a/resources/tester/test_b05_station_roundtrip.py +++ b/resources/tester/test_b05_station_roundtrip.py @@ -83,7 +83,7 @@ def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path): from B05_Profile.B05_Profile_Structures_Schema import StructureInstance item = StructureInstance( - type_id="cross_drain_exposed", + type_id="cross_underdrain", # 측점 심는 점형(노출형은 322-1 ★1 로 횡단도 none) placement="point", chainage_m=119.8, memo="메모만", diff --git a/resources/tester/test_b06_draft_pipe_replay.py b/resources/tester/test_b06_draft_pipe_replay.py index 349195287..4b034f681 100644 --- a/resources/tester/test_b06_draft_pipe_replay.py +++ b/resources/tester/test_b06_draft_pipe_replay.py @@ -5,6 +5,7 @@ 저장값(월류 폭 10 · 관경 1000)으로 돌아가 있었다. 초안(관 목록 세션 초안)이 정본이므로 횡단 보기가 서며 초안 관 옵션을 캐시에 다시 얹는다. • 되얹기 — 세월교 월류 폭 3.5 · 배수관 관경 1200 이 캐시 스펙에 실림 · 조정창 정본(기슭막이 높이 · 형태)은 안 건드림 + (323-6 — 옛 `replayDraftPipeOptions` 걷음 · 형상 얹기 한 벌이 초안 현황으로 다시 셈) • 횡단 보기가 먼저 선 뒤 종단 보기가 서도 종단 보기가 배수유역도의 관 목록을 먼저 쥠(빈 목록으로 시작해 관을 지우지 않음) 화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b06_draft_pipe_replay.cjs`). """ @@ -33,12 +34,15 @@ def test_draft_pipe_options_are_replayed_onto_the_cache(): out = json.loads(proc.stdout) assert out["fordSpan"] == 3.5 # 월류 폭 초안이 캐시 스펙에 실림(저장분 10 아님) assert out["culvertDiameter"] == 1.2 # 배수관 관경 초안 1200 - assert out["fordCalls"] == 1 # 옵션 없는 관은 건드리지 않고 세월교는 한 번만 + assert out["fordAt"] == [173.09] # 세월교는 제 측점에만 assert out["revetTouched"] is False # 기슭막이 높이 · 형태는 조정창이 정본 - # 배선 — 횡단 보기가 서며 초안을 읽어 되얹고, 종단 보기는 마지막 관 목록을 먼저 싣는다. + # 배선 — 옛 되얹기는 걷음(323-6) · 초안 현황으로 상세를 읽을 때마다 형상 얹기(`withDraftShapes`) · + # 종단 보기는 마지막 관 목록을 먼저 싣는다. cross = _read("A00_Common", "design", "design_view_cross.ts") - assert "replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId))" in cross + assert "replayDraftPipeOptions" not in cross + store = _read("B06_Section", "B06_Section_Section_Store.ts") + assert "await withDraftShapes(detail, projectId);" in store shared = _read("A00_Common", "design", "design_shared.ts") assert "shared.lastPipes = pipes" in shared profile = _read("A00_Common", "design", "design_view_profile.ts") diff --git a/resources/tester/test_b06_facility_default_notes.py b/resources/tester/test_b06_facility_default_notes.py index c675338eb..8fb824afa 100644 --- a/resources/tester/test_b06_facility_default_notes.py +++ b/resources/tester/test_b06_facility_default_notes.py @@ -57,6 +57,7 @@ def test_물넘이_월류_높이가_비면_파임_없음() -> None: def test_카드_머리가_목록과_까닭을_보인다() -> None: assert "기본값(미확정)" in CHROME - assert "월류 높이 없음 — 파임 안 그림" in CHROME + # 월류 높이 · 기슭막이 높이 등 필수 칸 빈칸 = 빈칸 경고 한 꼴(323-4 · 형상 얹기가 얹은 `blank`) + assert "section.blank" in CHROME and "빈칸`" in CHROME # 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길. assert CONTROLS.count("dropDefaulted(spec,") == 4 diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py index d16dc199c..fd758950e 100644 --- a/resources/tester/test_b06_structure_walls_mirror.py +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -81,7 +81,7 @@ writeFileSync(process.argv[3], JSON.stringify({ specs, sections })); def _python_result() -> dict: from B06_Section.B06_Section_Engine_Structures_Wall import ( - _FORM_BY_TYPE, + _wall_form, attach_wall_structures, tier_extras, ) @@ -102,7 +102,8 @@ def _python_result() -> dict: "start_m": float(min(start, end)), "end_m": float(max(start, end)), "anchor_m": float(item["chainage_m"]), - "form": options.get("form") or _FORM_BY_TYPE.get(item["type_id"]), + # 빈 형태 = 구조물 표 형상칸.form(322-1 · 옛 `_FORM_BY_TYPE`) + "form": options.get("form") or _wall_form(item["type_id"]), "height_m": options.get("height_m"), "side": options.get("side"), # 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면 diff --git a/resources/tester/test_b06_wall_drain_pipe.py b/resources/tester/test_b06_wall_drain_pipe.py index 62502cca2..f55524466 100644 --- a/resources/tester/test_b06_wall_drain_pipe.py +++ b/resources/tester/test_b06_wall_drain_pipe.py @@ -122,6 +122,13 @@ def test_옹벽만_높이_15m_띠마다_파이프_하나(tmp_path: Path) -> None def test_옹벽_표식은_구조물_종류로() -> None: source = (B06 / "B06_Section_UI_Cross_Revetment.ts").read_text(encoding="utf-8") - assert 'spec.type_id === "retaining_wall"' in source + # 물빼기 표식 = 구조물 표 형상칸.weep(322-1) — 처음값은 옹벽만 + assert "catalogRow(spec.type_id)?.shapeOpts.weep" in source + start = json.loads( + (PROJECT_ROOT / "common_util" / "common_util_structure_parts.json").read_text("utf-8") + ) + assert [t for t, v in start["처음값"].items() if v.get("형상칸", {}).get("weep")] == [ + "retaining_wall" + ] css = (B06 / "B06_Section_UI_Style_Cross_Areas.css").read_text(encoding="utf-8") assert re.search(r"\.b06-chart__wall-drain line \{[^}]*var\(--color-", css), "색 토큰 없음" diff --git a/resources/tester/test_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py index c344372c5..108ff9196 100644 --- a/resources/tester/test_m02_structure_def_screen.py +++ b/resources/tester/test_m02_structure_def_screen.py @@ -47,7 +47,8 @@ def _screen_edit(doc: dict) -> dict: pipe["name"] = "배수관(고침)" kind = next(o for o in pipe["options"] if o["key"] == "pipe_kind") kind["label"] = "관 종류" - kind["choices"] = ["흄관(고침)" if c == "흄관" else c for c in kind["choices"]] + # 선택지 값은 만든 뒤 고정(322-2) — 이름은 이름 표(`종류.pipe.이름`)로 + doc["종류"].setdefault("pipe", {}).setdefault("이름", {})["pipe.kind=흄관"] = "흄관(고침)" pipe["options"].append( { "key": "opt_ab12cd", @@ -62,7 +63,7 @@ def _screen_edit(doc: dict) -> dict: doc["구조물군"] = {k: doc["구조물군"][k] for k in [first[1], first[0], *first[2:]]} doc["종류"].setdefault("pipe", {})["기준측점"] = False doc["배치"]["pipe"] = {"pipe_kind": {"줄": 1, "칸": 1, "너비": 2}} - doc["표시"]["pipe"] = {"inlet_type": False} + doc["표시"]["pipe"] = {"wing_wall_type": False} # 부품이 안 읽는 칸(322-2 읽는 칸은 못 숨김) doc["그룹"]["pipe"][1]["형상"] = { "길이": {"받기": True, "뜻": "유입 보호공 연장"}, "높이": {"받기": False, "뜻": ""}, @@ -70,7 +71,7 @@ def _screen_edit(doc: dict) -> dict: doc["규칙"] = [ { "종류": "pipe", - "만약": {"옵션": "pipe_kind", "값들": ["흄관(고침)"]}, + "만약": {"옵션": "pipe_kind", "값들": ["흄관"]}, "그러면": {"옵션": "opt_ab12cd", "선택지": ["새 선택지 2"], "기본값": "새 선택지 2"}, }, { @@ -91,8 +92,11 @@ def test_화면_고침을_그대로_받아_판_더하기_1_지난_판_길(copy) assert res.status_code == 200, res.text got = res.json() assert got["판"] == doc["판"] + 1 - for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙", "types"): + for key in ("구조물군", "종류", "배치", "표시", "그룹", "규칙"): assert got[key] == sent[key], key + # 옛 칸(drawing_views)은 [저장] 때 빠짐(323-7 · 아무도 안 읽음) — 나머지는 그대로 + retired = schema.without_retired + assert got["types"] == [retired(t) for t in sent["types"]] assert list(got["구조물군"]) == list(sent["구조물군"]) # 순서 그대로 rows = admin.get(f"{URL}/history").json() assert [r["판"] for r in rows] == [doc["판"]] and len(rows[0]["저장일"]) == 16