From 38e09559c4ff9f41b81d66c793666200dd3c07c3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 06:28:06 +0900 Subject: [PATCH 01/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20217=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=EA=B3=A0=20=EB=9D=BC=EB=B2=A8=20=3D=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=9C=20=EB=88=88=EA=B8=88=20=EC=88=A8=EA=B8=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=9C=84=EC=97=90=20=EB=86=93?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=95=88=20=EB=B9=84=EC=B9=98=EB=8A=94=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=C2=B7=20=ED=9A=A1=EB=8B=A8=EB=B0=B0?= =?UTF-8?q?=EC=88=98=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20=EB=95=8C?= =?UTF-8?q?=20=EB=A7=B9=EC=95=94=EA=B1=B0=EB=A1=9C=20=EC=95=88=20=EB=B0=94?= =?UTF-8?q?=EB=80=9C(=ED=98=95=EC=83=81=20=EC=97=86=EB=8A=94=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=3D=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index b7f6ea25d..11a3b8065 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -245,3 +245,4 @@ 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. 횡단배수 구조물변경을 할경우 해당 측점에서 맹암거 변경안됨. 만약 횡단도에 횡단배수 구조물 중 형상이 없는 횡단도의 경우 없는 걸로 변경하고 다시 다른것도 변경해야할것같은데? From 2641c15e29fc8cbf6d65e812a6563275abc28c6e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 06:52:02 +0900 Subject: [PATCH 02/27] =?UTF-8?q?fix(b05=C2=B7b06):=20=EC=A0=95=EB=B3=B8?= =?UTF-8?q?=20=EB=8B=A4=EB=A5=B8=20=EC=A2=85=EB=A5=98(=EA=B4=80=20?= =?UTF-8?q?=E2=86=94=20=EB=A7=B9=EC=95=94=EA=B1=B0=20=C2=B7=20=EA=B0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=93=B1)=EB=A1=9C=20=EB=B0=94=EA=BF=88=20=3D=20?= =?UTF-8?q?=EC=98=9B=20=EA=B2=83=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=B8=A1=EC=A0=90=EC=97=90=20=EC=83=88=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=C2=B7=20=EC=9D=BC=EB=B0=98=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=9B=80=20=3D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=A7=8C=20=EB=BA=80=20=EC=82=AC=EB=B3=B8=EC=97=90=20=EC=83=88?= =?UTF-8?q?=20=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=A4=91=EC=8B=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=EC=97=90=20=EA=B2=B9=EC=B9=9C=20Y=20?= =?UTF-8?q?=EB=88=88=EA=B8=88=20=EC=88=A8=EA=B8=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=8C(=EC=83=81=EC=9E=90=20=EC=9C=84=20=C2=B7=20=EC=95=88?= =?UTF-8?q?=20=EB=B9=84=EC=B9=98=EB=8A=94=20=EB=B0=94=ED=83=95)=20(319=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4) - 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류 - 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../B05_Profile_UI_Structures_Panel.ts | 8 +- .../B05_Profile_UI_Structures_Panel_Events.ts | 27 +- B06_Section/B06_Section_Section_Drafts.ts | 28 +- B06_Section/B06_Section_UI_Cross_Axes.ts | 4 +- B06_Section/B06_Section_UI_Style_Cross.css | 3 +- resources/tester/helper_318_cross_card.cjs | 1 + resources/tester/helper_319_kind_swap.cjs | 319 ++++++++++++++++++ resources/tester/test_318_cross_card.py | 6 +- resources/tester/test_319_kind_swap.py | 115 +++++++ 9 files changed, 494 insertions(+), 17 deletions(-) create mode 100644 resources/tester/helper_319_kind_swap.cjs create mode 100644 resources/tester/test_319_kind_swap.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6c28e06a5..223e6b6fb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -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(), @@ -543,7 +542,7 @@ export function createStructuresSection( syncButtons(); renderList(); - return { + const api: StructuresSection = { root, facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15) listRoot: list, @@ -697,4 +696,5 @@ export function createStructuresSection( return true; }, }; + return api; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 392223cf9..77aebce06 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -8,7 +8,11 @@ * 처리 함수(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"; @@ -34,6 +38,8 @@ export interface StructuresEventContext extends StructuresCommitContext { commitPosition: () => void; liveCommit: () => void; loadForm: (target: StructureInstance | null) => void; + /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt`). */ + addAt: (chainageM: number, typeId: string) => void; emit: () => void; optionRow: HTMLElement; } @@ -82,9 +88,22 @@ 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); // 빈칸 = 새로 놓기 + // 정본이 다른 종류(관 지점 ↔ 구조물 — 맹암거 · 개거 등)로 = 옛 것을 지워 「없음」을 거친 뒤 같은 + // 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 기본값 · 요구 217) · 옛 조정값은 318-4 대로 버림 + if (!!next.managed_by !== !!prev.managed_by) { + const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; + if (record) { + ctx.structures.splice(index, 1); + ctx.setEditingId(null); + ctx.emit(); + } else { + ctx.callbacks.onPipeRemove(at); + ctx.setSelectedPipeChainage(null); + } + forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id); + return ctx.addAt(at, next.type_id); + } const key = record ? `s:${id}` : `p:${pipe}`; // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 stashTypeValues( diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 52854a601..6f43f57e4 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -103,7 +103,21 @@ async function previewOf( return chainages.flatMap((at) => previews.get(key(at)) ?? []); } -/** 미리 뜬 횡단에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ +/** 일반 측점 자리(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; +} + +/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ function decorate( fresh: CrossSection[], pipes: Pipes, @@ -138,6 +152,7 @@ function decorate( section.preview = true; const hit = labels.find(([at]) => near(at, section.chainage_m)); if (hit) section.structure = hit[1]; + else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217) } const own = pipes.filter((pipe) => fresh.some((section) => near(section.chainage_m, pipe.chainage_m)), @@ -206,19 +221,22 @@ 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)); + (section.grid + ? replaced(section) + : planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); stale = all.filter(isStale); - live = all.filter((section) => !isStale(section)); + const bare = stale.filter((section) => section.grid).map(bareOf); + live = [...all.filter((section) => !isStale(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], pipes, structures, types); } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 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_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/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 06008298a..054576849 100644 --- a/resources/tester/helper_318_cross_card.cjs +++ b/resources/tester/helper_318_cross_card.cjs @@ -277,6 +277,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..b26796131 --- /dev/null +++ b/resources/tester/helper_319_kind_swap.cjs @@ -0,0 +1,319 @@ +/* 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 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 (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 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: () => {} }, + "./B06_Section_Section_Drafts": { forgetPreviewAdjusts: () => {} }, + }, + ); + 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 } }); + const run = ({ structures = [], pipes = [], editing = null, pipeAt = null, next }) => { + const calls = { + 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), + emit: () => (calls.emit += 1), + addAt: (at, typeId) => calls.addAt.push([at, typeId]), + interval: () => 20, + }; + bindStructuresEvents(ctx); + ctx.typeSelect.listeners.change(); + return { ...calls, left: structures.map((s) => s.type_id) }; + }; + 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 = { + ...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 = { + ...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 = { + ...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 = run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: "box_culvert", + }); + out.underdrain_to_open_ditch = run({ + structures: [ + { structure_id: "s2", type_id: "cross_underdrain", placement: "point", chainage_m: 60 }, + ], + editing: "s2", + next: "open_ditch", + }); + out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null }); +} + +// ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ────── +const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedStructures: [] }; +(async () => { + 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"), + ], + { + "../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 { withDraftStations, forgetDraftStations } = load(B06("B06_Section_Section_Drafts")); + 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/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..903fd7334 --- /dev/null +++ b/resources/tester/test_319_kind_swap.py @@ -0,0 +1,115 @@ +"""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"] == [None] + 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"] == [None] + 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_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 From d5c322ffa06846d258eff99f1e7b3b7e43544cc8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 07:00:58 +0900 Subject: [PATCH 03/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20218=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=90=EA=B2=80=20=E2=80=94=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=ED=98=84=ED=99=A9=ED=91=9C(?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8)=20=C2=B7=20=EA=B7=9C=EC=B9=99=20=3D=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=EA=B5=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B7=B8=20=EB=B0=96=20=C2=B7=20=EA=B5=B0=20=EB=84=98=EC=96=B4?= =?UTF-8?q?=EA=B0=80=EB=A9=B4=20=ED=8C=A8=EB=84=90=20=EB=A6=AC=EC=85=8B=20?= =?UTF-8?q?=C2=B7=20=EA=B5=B0=20=EC=95=88=20=EC=A2=85=EB=A5=98=20=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=3D=20=EA=B7=B8=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=EC=97=86=EB=8A=94=20=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=3D=20=ED=9A=A1=EB=8B=A8=EB=8F=84=20=EC=95=88=20=EB=8D=94?= =?UTF-8?q?=ED=95=A8=20=C2=B7=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 11a3b8065..6a8dfcf08 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -246,3 +246,4 @@ 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) 직접 브라우져확인하고 이번 기회에 구조물 점검을해보자. 지금까지 결과를보면 횡단배수군과 그 외 구조물들로 나눌수 있고 그룹을 넘어가는 경우에는 좌측패널이 리셋됨. 그리고 그룹내 다른 구조물을 선택하면 자유롭게 변경되며 해당선택지의 구조물그림으로 변경함. 이때 구조물이 그림이 없는 경우 횡단도추가 하지않고 있더라도 삭제한다. 일단 정밀 점검해서 테이블로 현재 드롭다운 메뉴의 구조물 리스트를 만들어 현황공유. 내용이 많으면 아티팩트로 From 74a6cd9134d11ede7ca1f92ac5a30018eed93efa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 09:10:54 +0900 Subject: [PATCH 04/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20219=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=B0=98=EC=98=81=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=A0=95=EB=A6=AC=20=EC=A0=9C=EC=95=88=20=E2=80=94?= =?UTF-8?q?=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=3D=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=98=84=ED=99=A9=20?= =?UTF-8?q?=C2=B7=20=ED=9A=A1=EB=8B=A8=EB=8F=84=20=C2=B7=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=ED=98=95=EC=83=81=20=C2=B7=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=203D?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=ED=98=95=EC=83=81=EC=9D=B4=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=A7=88=EB=8B=A4=20=EB=8B=AC=EB=A6=AC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=C2=B7=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EB=8F=99=EC=9E=91=20=ED=9D=90=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EB=8C=80=EB=B9=84=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EB=B3=B4=EA=B3=A0=EC=84=9C(=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 6a8dfcf08..3badcc9d0 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -247,3 +247,4 @@ 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측점 형상 들이 조건에따라 반영들이 바뀌고 있고 향후 유지보수나 구조물을 넣고 빼는데 계속 어려움이 발생되고 있어 정밀 분석해서 수정이 용이하고 오류가 없게 코드들의 구조를 잡아야 할것같은데 제안을 해줘. 대규모 리팩토링을 해서라도 잡고 넘어가는게 맞겠어. 일단 코드수정은 아니니 다이러그램을 이용해서 동작 플로우를 만들고 어떻게 정보들을 연결할지 현재와 비교 분석보고서를 만들고 코드반영할지 고민해보자 . 토큰 소모가 많아도 되니 꼼꼼히 분석해줘 From f8d5e0cbe1c9dcc6e94c1eea9d3fce600d64ddeb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 12:48:46 +0900 Subject: [PATCH 05/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20220=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B4=80=EB=A6=AC=20=ED=91=9C=20?= =?UTF-8?q?=3D=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=9C=EC=96=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=BD=94=EB=93=9C=EB=8A=94=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=EC=9D=84=20=EB=A7=8C=EB=93=A4=EC=96=B4=20=EB=91=90=EA=B3=A0=20?= =?UTF-8?q?=EC=BC=AC=20=C2=B7=20=EB=81=94=20=C2=B7=20=EB=93=9C=EB=A1=AD?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EC=97=90=20=EB=93=A4=EC=96=B4=EC=98=AC=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EB=8A=94=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EC=A0=95=ED=95=A8=20=C2=B7=20M02=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=98=B5=EC=85=98=20=EA=B8=80=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EB=8A=94=20=EB=A9=94=EB=89=B4=EC=99=80=20=ED=95=A9?= =?UTF-8?q?=EC=B3=90=20=EC=9A=B4=EC=98=81=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B0=80=20=EC=A0=90=EA=B2=80=20=C2=B7=20=EB=81=9D?= =?UTF-8?q?=EB=82=98=EB=A9=B4=20=EC=8B=A0=EA=B7=9C=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 3badcc9d0..20f0a0fb7 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -248,3 +248,4 @@ 217. (원문 · 10-14) 추가수정 1. Y축 숫자에 중심고 표현에서 근처에 있는 정규 Y 값들을 중첩되면 숨김처리하는데 숨김처리하지말고 중심고 라벨을 위해 위에배치하면 괜찮을것같은데? 만약 정규 Y값이 비치면 라벨의 배경색을 안비치게 변경하는것도 좋아. 2. 횡단배수 구조물변경을 할경우 해당 측점에서 맹암거 변경안됨. 만약 횡단도에 횡단배수 구조물 중 형상이 없는 횡단도의 경우 없는 걸로 변경하고 다시 다른것도 변경해야할것같은데? 218. (원문 · 10-14) 직접 브라우져확인하고 이번 기회에 구조물 점검을해보자. 지금까지 결과를보면 횡단배수군과 그 외 구조물들로 나눌수 있고 그룹을 넘어가는 경우에는 좌측패널이 리셋됨. 그리고 그룹내 다른 구조물을 선택하면 자유롭게 변경되며 해당선택지의 구조물그림으로 변경함. 이때 구조물이 그림이 없는 경우 횡단도추가 하지않고 있더라도 삭제한다. 일단 정밀 점검해서 테이블로 현재 드롭다운 메뉴의 구조물 리스트를 만들어 현황공유. 내용이 많으면 아티팩트로 219. (원문 · 10-14) 내가 판단하기에는 현재의 드롭다운 메뉴가 결국 최신의 구조물 현황되어야해. 하지만 지금 b05,b06의 횡단도, 하이라이트, 횡단도 구조물 형상, 구조물 하이라이트, 종단 측점, 종단 알약, 3d측점 형상 들이 조건에따라 반영들이 바뀌고 있고 향후 유지보수나 구조물을 넣고 빼는데 계속 어려움이 발생되고 있어 정밀 분석해서 수정이 용이하고 오류가 없게 코드들의 구조를 잡아야 할것같은데 제안을 해줘. 대규모 리팩토링을 해서라도 잡고 넘어가는게 맞겠어. 일단 코드수정은 아니니 다이러그램을 이용해서 동작 플로우를 만들고 어떻게 정보들을 연결할지 현재와 비교 분석보고서를 만들고 코드반영할지 고민해보자 . 토큰 소모가 많아도 되니 꼼꼼히 분석해줘 +220. (원문 · 10-14) 확인했고 별도의 구조물 관리 테이블을 운용하여 오류를 줄이자는 말이네. 여기에 추가로 검토해줘야 하는네용이 테이블관리하여 내가 제어했으면 좋겠어. 코드야 만들어두고 활성화할지 비활성화할지 어떤 메뉴들이 들어올지 내가 결정할 수있었으면 해. 그래서 m02에 보면 구조물의 옵션 텍스트를 변경할수있게 제어하는 메뉴가 있는데 이걸 통합하여 운영하는거야. 다시 정밀 점검 해봐. 완료되면 신규 계획서를 작성할거야 From 8dac0bf3f0fefdd0fe4190e99daafd100c5e451b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 13:22:30 +0900 Subject: [PATCH 06/27] =?UTF-8?q?chore(plan):=20321=20=C2=B7=20322=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B4=80=EB=A6=AC=20=ED=91=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=EA=B8=B0=EB=A1=9D=20=E2=80=94=20M02=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EC=B9=B8=EB=A7=88=EB=8B=A4=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B4=EB=82=98=20=C2=B7=20=EC=BD=94=EB=93=9C=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=2010=EC=A2=85=20=C2=B7=20=EA=B5=B0=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C=20=EC=95=88=20=EB=94=B0=EB=A6=84=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=95=88=20=EC=94=80=E3=80=8D=20B05=20=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EC=9D=B4=EB=A6=84=20=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=3D=20=E3=80=8C=EA=B8=B0=ED=83=80=E3=80=8D=20=EC=9C=A0=EB=A0=B9?= =?UTF-8?q?=20=C2=B7=20=EB=B0=94=EA=BF=88=20=EC=95=88=EC=A0=84=20=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20=EA=B7=B8=EB=A6=AC=EB=8A=94=20=EC=AA=BD=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EB=AA=A9=EB=A1=9D(=EC=9A=94=EA=B5=AC=20220=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=A0=95=EB=B0=80=20=EC=A0=90=EA=B2=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../조사/2026-10-14_321-322_구조물관리표.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/raw/조사/2026-10-14_321-322_구조물관리표.md 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 은 처음 · 노선 읽을 때만. From 6327fd99c35ed6fba3210beedc981c0fa81e0160 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 13:56:04 +0900 Subject: [PATCH 07/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20221=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B4=80=EB=A6=AC=20=ED=91=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84(PLAN=20322=20=C2=B7=20323)=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=3D=20=EB=9E=A9=ED=83=91=20sub1=20?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EA=B2=A9=EB=A6=AC=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=92=A4=20=EB=B3=91=ED=95=A9=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EA=B2=B0=EC=A0=95=20=C2=B7=20=EC=A1=B0=EC=8B=AC=20?= =?UTF-8?q?=C2=B7=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 20f0a0fb7..8e55dc7ab 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -249,3 +249,4 @@ 218. (원문 · 10-14) 직접 브라우져확인하고 이번 기회에 구조물 점검을해보자. 지금까지 결과를보면 횡단배수군과 그 외 구조물들로 나눌수 있고 그룹을 넘어가는 경우에는 좌측패널이 리셋됨. 그리고 그룹내 다른 구조물을 선택하면 자유롭게 변경되며 해당선택지의 구조물그림으로 변경함. 이때 구조물이 그림이 없는 경우 횡단도추가 하지않고 있더라도 삭제한다. 일단 정밀 점검해서 테이블로 현재 드롭다운 메뉴의 구조물 리스트를 만들어 현황공유. 내용이 많으면 아티팩트로 219. (원문 · 10-14) 내가 판단하기에는 현재의 드롭다운 메뉴가 결국 최신의 구조물 현황되어야해. 하지만 지금 b05,b06의 횡단도, 하이라이트, 횡단도 구조물 형상, 구조물 하이라이트, 종단 측점, 종단 알약, 3d측점 형상 들이 조건에따라 반영들이 바뀌고 있고 향후 유지보수나 구조물을 넣고 빼는데 계속 어려움이 발생되고 있어 정밀 분석해서 수정이 용이하고 오류가 없게 코드들의 구조를 잡아야 할것같은데 제안을 해줘. 대규모 리팩토링을 해서라도 잡고 넘어가는게 맞겠어. 일단 코드수정은 아니니 다이러그램을 이용해서 동작 플로우를 만들고 어떻게 정보들을 연결할지 현재와 비교 분석보고서를 만들고 코드반영할지 고민해보자 . 토큰 소모가 많아도 되니 꼼꼼히 분석해줘 220. (원문 · 10-14) 확인했고 별도의 구조물 관리 테이블을 운용하여 오류를 줄이자는 말이네. 여기에 추가로 검토해줘야 하는네용이 테이블관리하여 내가 제어했으면 좋겠어. 코드야 만들어두고 활성화할지 비활성화할지 어떤 메뉴들이 들어올지 내가 결정할 수있었으면 해. 그래서 m02에 보면 구조물의 옵션 텍스트를 변경할수있게 제어하는 메뉴가 있는데 이걸 통합하여 운영하는거야. 다시 정밀 점검 해봐. 완료되면 신규 계획서를 작성할거야 +221. (원문 · 10-14) 이번 수정은 일단 랩탑 서브 1에 반영하고나서 내가 직접 확인후 병합여부를 결정할거야. 그러니 조심하고 너도 계획서의 전체 내용을 반영하고 브라우져 검증진행해줘. From 863abc7766f11f940dd8825f3a4f3f383bfb6aab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 14:16:27 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20322-0=20=EC=95=88=EC=A0=84=EB=A7=9D=20=E2=80=94=2042?= =?UTF-8?q?=EC=A2=85=20=EC=A0=84=EC=88=98=20=ED=91=9C=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?(=EC=A7=80=EA=B8=88=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=C2=B7=20=E2=98=85=20=EB=B0=94=EB=80=94=20=EC=B9=B8=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C)=20=C2=B7=20=E3=80=8C=EA=B8=B0=ED=83=80?= =?UTF-8?q?=E3=80=8D=20=EC=9C=A0=EB=A0=B9=20=EA=B8=B8=20=EB=A7=89=EA=B8=B0?= =?UTF-8?q?(=EC=A7=80=EA=B8=88=20=C2=B7=20=EC=A7=80=EB=82=9C=20=ED=8C=90?= =?UTF-8?q?=20=EC=A2=85=EB=A5=98=20=EC=9D=B4=EB=A6=84=20=EC=A0=84=EB=B6=80?= =?UTF-8?q?)=20=C2=B7=20=EC=A2=85=EB=A5=98=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=90=20=ED=99=95=EC=9D=B8(=EB=B3=B4=EA=B8=B0=20=EC=97=B4?= =?UTF-8?q?=20=EB=95=8C=20=C2=B7=20=ED=83=AD=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=BC=20=EB=95=8C)=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EA=B4=80=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전수 표 시험 test_322_0_golden + helper_322_0_golden.cjs · 기록 golden_322_structures.json(측점 · 카드 이름 · 형상 열쇠 · 3D 몸체 · 알약 띠 · 고르기 목록 · 물빼기 · ★ 27칸) - 이관 = 지금 판 + 구조물정의_이력 판_N 종류 이름 측점은 안 옮김(all_type_names) - GET /api/m02/structure-definition/version · dropStaleStructureTypes · onStaleStructureTypes - _registry_defaults lru 걷음(정의 읽기가 이미 파일 시각 캐시) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_view_cross.ts | 13 +- A00_Common/design/design_view_profile.ts | 11 +- B05_Profile/B05_Profile_Api_Structures.ts | 42 +- .../B05_Profile_Structures_Migration.py | 15 +- B05_Profile/B05_Profile_Structures_Schema.py | 20 + B05_Profile/B05_Profile_UI_Page_Structures.ts | 2 + B06_Section/B06_Section_Engine_Culvert.py | 5 +- .../B06_Section_UI_Page_Structures_Panel.ts | 2 + .../StructOptions/M02_SO_Router.py | 8 + resources/tester/golden_322_structures.json | 2631 +++++++++++++++++ resources/tester/helper_322_0_golden.cjs | 337 +++ resources/tester/test_322_0_ghost.py | 73 + resources/tester/test_322_0_golden.py | 250 ++ resources/tester/test_322_0_version.py | 58 + 14 files changed, 3449 insertions(+), 18 deletions(-) create mode 100644 resources/tester/golden_322_structures.json create mode 100644 resources/tester/helper_322_0_golden.cjs create mode 100644 resources/tester/test_322_0_ghost.py create mode 100644 resources/tester/test_322_0_golden.py create mode 100644 resources/tester/test_322_0_version.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 2c603a687..fe849d939 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -17,9 +17,10 @@ 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, +import { + onStaleStructureTypes, + type StructureInstance, + type StructureType, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { fetchSectionContext, @@ -567,6 +568,12 @@ export async function mountCrossView( void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); + // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) + host.onDispose( + onStaleStructureTypes(() => { + if (host.visible()) void structuresPanel.load().then(restoreStructurePick); + }), + ); host.onDispose(() => { window.clearTimeout(pipeReloadTimer); window.clearTimeout(wallAdjustTimer); diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index be81dae68..d3b44d3d0 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -57,7 +57,10 @@ 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, +} from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { loadCorridorIfFresh, @@ -548,6 +551,12 @@ export async function mountProfileView( onReset: () => uphillOverrides.clear(), }); + // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) + host.onDispose( + onStaleStructureTypes(() => { + if (host.visible() && !restoring) void bridge.load(); + }), + ); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 52b2fc400..a31006619 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -192,8 +192,42 @@ 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 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 +246,11 @@ 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; + return withDefinition(payload.types, defined); + }) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 27345f602..0d8d33c52 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -21,7 +21,7 @@ from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, - station_planting_labels, + all_type_names, structure_type_map, ) from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint @@ -55,16 +55,13 @@ _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) def _managed_elsewhere_labels() -> set[str]: - """서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 → - "기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels). + """정본이 이미 관리하는 측점 이름 — 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). - 관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서 - 투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관). + 지금 판 · 지난 판 정의의 **종류 이름 전부**(322-0) — 심는 측점 이름 · 관 지점 시설 이름만 + 보던 옛 규칙은 M02 에서 종류 이름을 바꾸면 옛 이름 측점을 못 알아보고 「기타」로 굳혔다 + (이름 바꿈 · 심는 규칙 바뀜 둘 다). """ - owned = { - item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER - } - return station_planting_labels() | owned + return all_type_names() def _is_managed_elsewhere(entry: dict[str, Any]) -> bool: diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 64264b26d..ff1460d09 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -162,6 +162,26 @@ 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 all_type_names() -> set[str]: + """지금 판 + 지난 판 정의의 종류 이름 전부 — 이름을 바꾼 뒤 옛 이름 측점도 알아봄(322-0).""" + names = {item.name for item in load_structure_types()} + history = os.path.join(os.path.dirname(DEFINITION_PATH), HISTORY_DIRNAME) + for name in os.listdir(history) if os.path.isdir(history) else []: + if not (name.startswith("판_") and name.endswith(".json")): + continue + try: + with open(os.path.join(history, name), encoding="utf-8") as handle: + types = json.load(handle).get("types", []) + except (OSError, ValueError): + continue + names |= {str(t["name"]) for t in types if isinstance(t, dict) and t.get("name")} + return names + + def registry_schema_version() -> int: return int(_read_definition(*_stamp()).get("schema_version", 1)) diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 4b8f39300..e4158b73b 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -17,6 +17,7 @@ import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu"; import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchStructures, + dropStaleStructureTypes, fetchStructureTypes, saveStructures, readPendingStructures, @@ -346,6 +347,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. + await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0) const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), fetchStructures(deps.projectId), diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 67d055929..ffea678bb 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -25,7 +25,6 @@ import json import logging import math import re -from functools import lru_cache from pathlib import Path from typing import Any @@ -147,12 +146,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: diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 4cd5736e3..9acae7a03 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -20,6 +20,7 @@ import { } from "../A00_Common/design/design_shared"; import { fetchStructures, + dropStaleStructureTypes, fetchStructureTypes, readPendingStructures, pipesToStructureMarks, @@ -369,6 +370,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군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 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/resources/tester/golden_322_structures.json b/resources/tester/golden_322_structures.json new file mode 100644 index 000000000..c40adad68 --- /dev/null +++ b/resources/tester/golden_322_structures.json @@ -0,0 +1,2631 @@ +{ + "★ 메모": [ + "★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 관 공식 한 벌 — 표시 이름도 정의 관종을 앎" + } + ], + "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_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs new file mode 100644 index 000000000..b60244658 --- /dev/null +++ b/resources/tester/helper_322_0_golden.cjs @@ -0,0 +1,337 @@ +/* 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`, 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, + 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")); + +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: (type) => type.enabled, b06: () => true }; + 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(); + 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/test_322_0_ghost.py b/resources/tester/test_322_0_ghost.py new file mode 100644 index 000000000..68e3217df --- /dev/null +++ b/resources/tester/test_322_0_ghost.py @@ -0,0 +1,73 @@ +"""PLAN 322-0 ② — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 구조물로 되옮기지 않음. + +정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꾸고 앞 판을 `구조물정의_이력/판_N.json` 으로 둠 → +옛 이름 · 새 이름 측점 모두 이관 0건. 옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 종전대로 옮김. +""" + +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) -> int: + plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}]) + return len(plan.structures) + len(plan.pipe_facilities) + + +def test_old_and_new_names_are_not_migrated(renamed: dict) -> None: + 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) == 0, old_name + assert _moved(name) == 0, name + + +def test_every_type_name_of_any_version_is_blocked(renamed: dict) -> None: + names = {t["name"] for t in renamed["types"]} | set(RENAMED.values()) + assert names <= schema.all_type_names() + assert [name for name in sorted(names) if _moved(name)] == [] + + +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", "돌망태 보강")] + + +def test_master_history_names_are_read() -> None: + """실제 마스터 이력(판_1 ~)의 이름도 모음 — 이력 파일은 읽기만.""" + history = MASTER.parent / schema.HISTORY_DIRNAME + names = set() + for path in history.glob("판_*.json"): + names |= {t["name"] for t in json.loads(path.read_text(encoding="utf-8"))["types"]} + assert names <= schema.all_type_names() diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py new file mode 100644 index 000000000..d9ee6099e --- /dev/null +++ b/resources/tester/test_322_0_golden.py @@ -0,0 +1,250 @@ +"""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 = 0 +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"]} + + +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 _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} From bbd62548924ed9eac175366c9545bfc87932b45c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 15:09:45 +0900 Subject: [PATCH 09/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20322-1=20=EB=B6=80=ED=92=88=20=EC=83=81=EC=9E=90=20=C2=B7?= =?UTF-8?q?=20=ED=91=9C=20=EC=9D=BD=EA=B8=B0=20=E2=80=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=98=95=EC=83=81=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=203D=20=EB=AA=B8=EC=B2=B4?= =?UTF-8?q?=20=C2=B7=20=EC=95=8C=EC=95=BD=20=EB=9D=A0=EA=B0=80=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=ED=82=A4=20=C2=B7=20=EA=B5=B0=20=EA=B8=80=EC=9E=90?= =?UTF-8?q?=20=C2=B7=20=EC=9E=90=EB=A6=AC=EA=BC=B4=20=C2=B7=20managed=5Fby?= =?UTF-8?q?=20=EB=8C=80=EC=8B=A0=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=91=9C?= =?UTF-8?q?(catalog)=20=ED=95=9C=20=EC=A4=84=EC=9D=84=20=EC=9D=BD=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EC=BD=94=EB=93=9C=20=EC=88=A8=EA=B9=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=E2=98=85=20=EB=84=B7?= =?UTF-8?q?=20+=20=EA=B4=80=20=EA=B3=B5=EC=8B=9D=20=ED=95=9C=20=EB=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부품 상자 common_util_structure_parts.json(갈래마다 부품 · 읽는 칸 · 자리꼴 · 정본 · 42종 처음값) · 표 읽기 common_util_structure_catalog.ts / .py 짝(정의 반영 ⊕ 처음값 ⊕ 부품 끔) · 거울 시험 test_322_1_catalog_mirror - 목록: pickGroups 군 차례 = 구조물군 열쇠 · 페이지 거르기 listPick(B05 both · B06 both + b06) · HIDDEN_PICK_GROUPS · HIDDEN_PICK_TYPES · pickable 걷음(옛 대피 셋 asRoad 그대로) - 측점: structureStationMarks · pipeStationMarks · is_station_planting_type · resolve_extra_stations = 표 횡단도(anchor · span3 · none) - 카드 이름: cardTitle / card_title 한 공식(빈칸 = 그 종류 정의 기본값) · 표시 이름이 정의 관종도 앎 - 형상: 벽 = shape wall(형태 = 형상칸.form · 물빼기 = 형상칸.weep) · 포장 = shape pavement(「사용 중」 거르기 없앰) · 맹암거 · 사토 · 관 지점 시설 세트 = shape - 3D 몸체 = 그림 자료 주인 종류의 표 3D 부품 · 알약 띠 = 표 자리꼴 구간(관 지점 시설은 알약만) - ★1 노출형 · 개거 횡단도 none · ★2 쇄석 화면에서도 포장층 · ★3 기슭막이 표 자리꼴 interval(정의 파일 그대로) · ★4 코드 숨김 10종 list hidden · ★5 관 빈칸 D800 → 정의 기본 D1000 - 옛 시험: 도움 파일 로더 27개가 표 모듈 공용 한 벌(helper_322_catalog_module) 씀 · 기대값 고침(272_1 · b05_extra_stations · roundtrip · 314 · 52_7 · walls 거울 · wall_drain_pipe) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_Api_Structures.ts | 2 + B05_Profile/B05_Profile_Engine_Sections.py | 31 +- B05_Profile/B05_Profile_Structures_Schema.py | 19 +- .../B05_Profile_UI_Corridor_Structures.ts | 23 +- ...B05_Profile_UI_Corridor_Structures_Pick.ts | 21 + B05_Profile/B05_Profile_UI_Page_Structures.ts | 3 +- .../B05_Profile_UI_Structures_Define.ts | 5 +- .../B05_Profile_UI_Structures_Marks.ts | 8 +- .../B05_Profile_UI_Structures_Panel.ts | 4 +- .../B05_Profile_UI_Structures_Panel_Types.ts | 46 +- B06_Section/B06_Section_Engine_Culvert.py | 22 +- B06_Section/B06_Section_Engine_SpoilFill.py | 5 +- .../B06_Section_Engine_Structures_Wall.py | 31 +- B06_Section/B06_Section_Engine_Tilt.py | 4 +- B06_Section/B06_Section_Section_Drafts.ts | 17 +- B06_Section/B06_Section_Section_Store.ts | 51 +- B06_Section/B06_Section_UI_Cross_Revetment.ts | 4 +- .../B06_Section_UI_Page_Structures_Panel.ts | 3 +- B06_Section/B06_Section_UI_Section_Common.ts | 6 +- .../B06_Section_UI_Section_View_Menu.ts | 3 +- common_util/common_util_culvert_sets.ts | 25 +- common_util/common_util_structure_catalog.py | 126 +++++ common_util/common_util_structure_catalog.ts | 174 +++++++ common_util/common_util_structure_parts.json | 485 ++++++++++++++++++ common_util/common_util_structure_pavement.py | 4 +- common_util/common_util_structure_station.ts | 20 +- .../common_util_structure_underdrain.ts | 6 +- common_util/common_util_structure_walls.ts | 17 +- resources/tester/helper_107_ford_free.cjs | 2 + .../tester/helper_116_right_click_add.cjs | 2 + .../tester/helper_129_menu_equals_panel.cjs | 13 +- .../tester/helper_136_chute_form_cache.cjs | 2 + .../tester/helper_139_4_buried_outlet.cjs | 2 + resources/tester/helper_152_3_chute_cut.cjs | 2 + .../tester/helper_153_b07_cross_layout.cjs | 2 + resources/tester/helper_165_pavement_flow.cjs | 2 + .../tester/helper_173_7_inlet_animal.cjs | 2 + resources/tester/helper_178_3_slope_tiers.cjs | 2 + resources/tester/helper_219_1_arrow_owner.cjs | 2 + resources/tester/helper_231_1_pave_reload.cjs | 2 + .../tester/helper_234_1_structure_station.cjs | 6 +- .../tester/helper_272_1_station_list.cjs | 2 + .../tester/helper_273_2_preview_stations.cjs | 2 + resources/tester/helper_284_1_pill_pick.cjs | 2 + .../tester/helper_286_1_ford_pavement.cjs | 2 + resources/tester/helper_288_underdrain.cjs | 2 + resources/tester/helper_313_clear_all.cjs | 2 + .../tester/helper_314_stale_structure.cjs | 2 + .../tester/helper_317_kind_change_station.cjs | 2 + resources/tester/helper_317_stale_preview.cjs | 2 + resources/tester/helper_319_kind_swap.cjs | 2 + resources/tester/helper_322_0_golden.cjs | 5 +- resources/tester/helper_322_1_catalog.cjs | 43 ++ .../tester/helper_322_catalog_module.cjs | 23 + resources/tester/helper_54_10_soil_tiers.cjs | 2 + .../tester/helper_54_12_live_required.cjs | 2 + .../tester/helper_54_27_soil_tier_field.cjs | 2 + .../tester/helper_b05_station_roundtrip.cjs | 9 +- .../helper_b05_structures_form_summary.cjs | 2 + .../tester/helper_b06_adjust_single_form.cjs | 2 + .../tester/helper_b06_draft_pipe_replay.cjs | 2 + resources/tester/test_272_1_station_list.py | 3 +- resources/tester/test_314_stale_structure.py | 2 +- resources/tester/test_322_0_golden.py | 2 +- resources/tester/test_322_1_catalog_mirror.py | 229 +++++++++ resources/tester/test_52_7_safety.py | 11 +- .../test_b05_extra_stations_supplier.py | 10 +- .../tester/test_b05_station_roundtrip.py | 2 +- .../tester/test_b06_structure_walls_mirror.py | 5 +- resources/tester/test_b06_wall_drain_pipe.py | 9 +- 70 files changed, 1384 insertions(+), 207 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts create mode 100644 common_util/common_util_structure_catalog.py create mode 100644 common_util/common_util_structure_catalog.ts create mode 100644 common_util/common_util_structure_parts.json create mode 100644 resources/tester/helper_322_1_catalog.cjs create mode 100644 resources/tester/helper_322_catalog_module.cjs create mode 100644 resources/tester/test_322_1_catalog_mirror.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index a31006619..daf7bb4cc 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -14,6 +14,7 @@ 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 { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { type DefinedRule, @@ -249,6 +250,7 @@ export function fetchStructureTypes(includeDisabled = false): Promise { const defined = await definition; typesVersion = defined?.판 ?? null; + useCatalogDefinition({ types: payload.types, ...defined }); // 구조물 표(322-1 · 반영 · 부품) return withDefinition(payload.types, defined); }) .catch((error) => { diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 7f8987fae..ae637bc1b 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -29,8 +29,6 @@ from B05_Profile.B05_Profile_Structures_Schema import ( 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 +41,13 @@ 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, catalog_row 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 ( @@ -277,10 +270,12 @@ def resolve_extra_stations( label = pipe_station_label(pipe, types) if label is None: continue - # 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 — - # 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은 - # 기준 한 곳(관이 노선을 가로지르는 점형). - if pipe.facility == PIPE_FACILITY_REVET: + # 표 횡단도(322-1) — span3(독립 기슭막이) = **시작·기준·종료** 세 측점(길수록 횡단도가 + # 여러 장) · anchor(그 밖 관 시설) = 기준 한 곳(관이 노선을 가로지르는 점형) · none = 없음. + station = (catalog_row(pipe.facility) or {}).get("station", "anchor") + if station == "none": + continue + if station == "span3": marks = (pipe.start_m, pipe.chainage_m, pipe.end_m) else: marks = (pipe.chainage_m,) @@ -300,7 +295,7 @@ def resolve_extra_stations( continue if not is_station_planting_type(definition): continue - if definition.placement == "interval": + if catalog_row(structure.type_id)["station"] == "span3": marks = (structure.start_m, structure.chainage_m, structure.end_m) else: marks = (structure.anchor_m(),) diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index ff1460d09..8f4e42d95 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -141,20 +141,15 @@ 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]: diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 2a6b0c3fc..f0845d294 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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 } from "./B05_Profile_UI_Corridor_Structures_Pick"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -276,17 +277,25 @@ export function buildCorridorStructures( ): CorridorStructure[] { const solids: CorridorStructure[] = []; for (const section of crossSections) { - const layout = culvertLayoutOf(section); + // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) + const layout = culvertBody(section) ? culvertLayoutOf(section) : null; // 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1) - const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout); + const fordLayout = 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) + const revetLayout = + 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 = 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, @@ -447,7 +456,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_Pick.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts new file mode 100644 index 000000000..2135085c0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts @@ -0,0 +1,21 @@ +/* ============================================================================= + * 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; + +/** `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_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index e4158b73b..e9080f96d 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -36,6 +36,7 @@ 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 { listPick } from "../common_util/common_util_structure_catalog"; import { pipeStationMarks, SAME_STATION_M, @@ -352,7 +353,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { 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); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts index b3ef3f123..c964d5623 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -216,7 +216,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 +225,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_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 223e6b6fb..55dbc07e3 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 : ""; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 71c974d11..73856b16f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -21,7 +21,7 @@ import { 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 { 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` 그대로. */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ffea678bb..a7477cb60 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -29,14 +29,8 @@ 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 config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_EDITS_DIRNAME, @@ -612,18 +606,20 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: return {} sets: dict[float, dict[str, Any]] = {} for point in parse_pipe_points(document.get("points")): - if point.facility == PIPE_FACILITY_PIPE: + # 그릴 세트 = 구조물 표 형상(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) 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..2b7087c15 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -24,21 +24,21 @@ 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 catalog_row logger = logging.getLogger(__name__) # 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. _EDGE_TOLERANCE_M = 0.02 -# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태는 벽 안 무늬만 정하므로 -# 가장 가까운 것으로 잇는다. -_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`. @@ -71,17 +71,16 @@ 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: + 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: @@ -96,7 +95,7 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: "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), + "form": options.get("form") or _wall_form(structure.type_id), "height_m": options.get("height_m"), # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index 3fa30b969..ef57abf16 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -17,10 +17,10 @@ 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 logger = logging.getLogger(__name__) -UNDERDRAIN_TYPE_ID = "cross_underdrain" _EDGE_TOLERANCE_M = 0.02 TiltRange = tuple[float, float, float | None] @@ -44,7 +44,7 @@ def load_underdrains(project_root: Path) -> list[dict[str, Any]]: return [] 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")) diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 6f43f57e4..c72eb4631 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -31,6 +31,7 @@ import { SAME_STATION_M, structureStationMarks, } from "../common_util/common_util_structure_station"; +import { cardTitle } from "../common_util/common_util_structure_catalog"; import { previewStructureSections, type CrossSection, @@ -126,22 +127,16 @@ function decorate( ): 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))}`; - }; + // 카드 이름 = 표 카드 이름(322-1 · 배관 = 관종 D관경 · 빈칸 = 그 종류 정의 기본값 · 그 밖 = 종류 이름) const labels: Array<[number, string]> = [ ...pipes.flatMap((pipe) => { - const label = pipeLabel(pipe); - return label - ? pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]) - : []; + const facility = pipe.facility ?? "pipe"; + const label = cardTitle(facility, pipe.options, registry[facility]); + return 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; + const label = cardTitle(structure.type_id, structure.options); return structureStationMarks(structure, type).map((m): [number, string] => [ m.chainage_m, label, diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 6ec26a7ce..0767293a2 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -40,6 +40,7 @@ import { type PavementStructureInput, } from "@util/common_util_structure_pavement"; import { setGeneralStations } from "../common_util/common_util_structure_station"; +import { cardTitle, catalogRow } from "../common_util/common_util_structure_catalog"; import { attachUnderdrainSpecs, underdrainSpecsFrom, @@ -85,9 +86,11 @@ async function withDraftWalls( detail.cross_sections as unknown as Array>, underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]), ); - const types = await fetchStructureTypes().catch(() => []); + // 표 형상(322-1) — 「사용 중」 거르기 없음(꺼진 종류도 그 그림대로 · 쇄석 포장층) + const types = await fetchStructureTypes(true).catch(() => []); + const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape); // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) - const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id)); + const paveTypes = new Set(shaped("pavement").map((type) => type.type_id)); if (paveTypes.size) { for (const section of detail.cross_sections) delete section.pavement; attachPavementSpecs( @@ -95,15 +98,8 @@ async function withDraftWalls( 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), - ); + // 벽 = 표 형상 wall(소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 자리에 안 얹음 · 계획서 3-9) + const names = new Map(shaped("wall").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; @@ -115,16 +111,17 @@ async function withDraftWalls( return withStructureAreas(detail); } -/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */ +/** 관 지점 시설 → 횡단 자리 키 = 표 형상(파이썬 `_SECTION_KEYS` · 배관 세트 · 독립 기슭막이 = culvert). */ const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; +const SHAPE_KEY: Record = { + box: "box", + ford: "ford", + ford_pavement: "ford_pavement", +}; +const shapeOf = (facility: string | null | undefined): string => + catalogRow(facility ?? "pipe")?.shape ?? "pipe_set"; const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => - facility === "ford_bridge" - ? "ford" - : facility === "box_culvert" - ? "box" - : facility === "ford_pavement" - ? "ford_pavement" - : "culvert"; + SHAPE_KEY[shapeOf(facility)] ?? "culvert"; /** * 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다. @@ -141,13 +138,14 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri 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); + shapeOf(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")); + (key !== "culvert" || + (spec.hidden_pipe === true) === (shapeOf(pipe.facility) === "hidden_revet")); const stale = pipes.filter((pipe) => { const here = sections.flatMap((section) => FACILITY_KEYS.flatMap((key) => { @@ -156,18 +154,18 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri }), ); // 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314) - if (!here.length) return pipe.facility === "ford_pavement"; + if (!here.length) return shapeOf(pipe.facility) === "ford_pavement"; return !here.some(([key, spec]) => fits(pipe, key, spec)); }); if (!stale.length) return; - const types = await fetchStructureTypes().catch(() => []); + const types = await fetchStructureTypes(true).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관경 · 그 밖 = 종류 이름) + // 카드 제목 구조물 이름 = 표 카드 이름(서버 `pipe_station_label` 짝 · 배관 = 관종 D관경 · 그 밖 = 종류 이름) // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점 const pipe = stale.find((entry) => { const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); @@ -175,10 +173,7 @@ async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: stri }); 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; + section.structure = cardTitle(facility, pipe.options, registry[facility]); } attachCulvertSets(sections, buildCulvertSets(stale, registry)); } diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index db6536233..14a9abba0 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -25,6 +25,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"; @@ -277,7 +278,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 사용자 확정). diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 9acae7a03..379f7f71f 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -60,6 +60,7 @@ 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"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; @@ -379,7 +380,7 @@ 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; 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_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/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_structure_catalog.py b/common_util/common_util_structure_catalog.py new file mode 100644 index 000000000..289156c93 --- /dev/null +++ b/common_util/common_util_structure_catalog.py @@ -0,0 +1,126 @@ +"""구조물 표(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"] + base = start.get(type_id) or {} + 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, + } + 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 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 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..f26739e63 --- /dev/null +++ b/common_util/common_util_structure_catalog.ts @@ -0,0 +1,174 @@ +/* ============================================================================= + * 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; +} + +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; +} + +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 base = start[id] ?? {}; + 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, + }); + }); + 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); + +/** 고르기 목록에 보일 종류인가 — 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}`; +} + +/** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */ +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..ceef3a46b --- /dev/null +++ b/common_util/common_util_structure_parts.json @@ -0,0 +1,485 @@ +{ + "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 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"] + }, + "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"] + }, + "hidden_revet": { + "이름": "독립 기슭막이(관 숨김 벽)", + "읽는 칸": [ + "form", + "height_m", + "length_m", + "before_m", + "after_m", + "tiers", + "side", + "foundation", + "face_role", + "face_slope_ratio" + ], + "자리꼴": ["interval"], + "정본": ["pipe_points"] + }, + "wall": { + "이름": "벽(옹벽 · 흙막이 · 돌쌓기)", + "읽는 칸": [ + "form", + "height_m", + "side", + "foundation", + "tiers", + "lift_m", + "shift_m", + "face_role", + "face_slope_ratio" + ], + "자리꼴": ["interval"], + "정본": ["structures"] + }, + "berm": { + "이름": "소단(사면 계단)", + "읽는 칸": ["width_m", "interval_m", "slope_deg", "depth_m"], + "자리꼴": ["interval"], + "정본": ["structures"] + }, + "underdrain": { + "이름": "맹암거", + "읽는 칸": ["culvert_height_m", "culvert_slope_pct"], + "자리꼴": ["point"], + "정본": ["structures"] + }, + "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"] + } + } + }, + "처음값": { + "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..11411dde6 100644 --- a/common_util/common_util_structure_pavement.py +++ b/common_util/common_util_structure_pavement.py @@ -105,10 +105,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_station.ts b/common_util/common_util_structure_station.ts index ac0cb7d52..43bd4c06e 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -9,6 +9,8 @@ * 일반 측점 = 격자 · 시종점(구조물 측점 kind "irregular" 은 뺌) — 끝(노선 길이)을 넘지 않게 자르는 데만 · * 상세를 읽을 때 한 번 둔다. * + * 심는 측점 = 구조물 표(catalog) 횡단도 부품(322-1) — anchor = 기준 하나 · span3 = 시작 · 기준 · 끝 · none = 없음. + * * 측점 한 벌(272) — 측점을 심는 관 · 구조물(`plantedChainages`) = 서버 `resolve_extra_stations` * (B05_Profile_Engine_Sections.py) 짝 · 같은 m 면 측점 하나 · 구조물이 위(`oneStationList`) = 서버 * `_merge_irregular_into_longitudinal`(B05_Profile_Router_Confirm.py) 짝. 거울 시험 = @@ -16,6 +18,8 @@ * 이 목록 하나를 씀. * ========================================================================== */ +import { catalogRow } from "./common_util_structure_catalog"; + const EPS = 1e-6; /** 같은 측점으로 볼 거리(m) — 서버 측점 만들기의 격자 붙임 폭(`Engine_Sections_Core`)과 같음. */ @@ -111,22 +115,22 @@ 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` 의 누가거리. */ 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..1cdc8ca17 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 { 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 => @@ -117,7 +118,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/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..1853a63c6 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: () => () => {} }); 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..3eee4915c 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}`); }; 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..70d7a0fd8 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}`); }; 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_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..48c397c6f 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]; }); 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..85d319513 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}`); }; 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_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..6b4a74352 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`)); 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_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index b26796131..88bdd05af 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.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 B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); @@ -65,6 +66,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`); diff --git a/resources/tester/helper_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs index b60244658..39c462d16 100644 --- a/resources/tester/helper_322_0_golden.cjs +++ b/resources/tester/helper_322_0_golden.cjs @@ -134,7 +134,7 @@ const STUBS = new Map([ [file("ui_template", "ui_template_elements.ts"), { showToast: () => {} }], ]); const resolveFile = (base) => - [base, `${base}.ts`, path.join(base, "index.ts")].find( + [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(); @@ -232,6 +232,7 @@ 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); @@ -239,7 +240,7 @@ async function main() { const out = { samples: {}, pick: {} }; // 고르기 목록 — 페이지가 넘기는 거르기 그대로(B05 = 켜진 것 · B06 = 전부) - const pagePicks = { b05: (type) => type.enabled, b06: () => true }; + 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)) 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_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_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_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..50718cd1b 100644 --- a/resources/tester/helper_b06_draft_pipe_replay.cjs +++ b/resources/tester/helper_b06_draft_pipe_replay.cjs @@ -7,6 +7,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; // 화면 공용 부품(@ui/…)은 이 시험이 안 쓰는 것이라 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); if (request.startsWith("@util/")) diff --git a/resources/tester/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py index 82181a3d4..61c6c61af 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", @@ -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, 530, 545, 560] # 서버 종단 측점(맞추기 전) — 격자 0~100 · 옛 관 측점 95.38 · 구조물이 떠난 격자 자리 40(`grid`) · # 옛 판 「regular + 글」 60 · 새 목록 = 격자 위 100(구조물이 위) · 새 자리 35 diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py index 0b680b005..302515919 100644 --- a/resources/tester/test_314_stale_structure.py +++ b/resources/tester/test_314_stale_structure.py @@ -78,4 +78,4 @@ def test_structure_change_reconciles_and_ford_reattach() -> None: 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 + assert 'if (!here.length) return shapeOf(pipe.facility) === "ford_pavement";' in kinds diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py index d9ee6099e..69948eccf 100644 --- a/resources/tester/test_322_0_golden.py +++ b/resources/tester/test_322_0_golden.py @@ -36,7 +36,7 @@ ROOT = HERE.parent.parent GOLDEN = HERE / "golden_322_structures.json" FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json" #: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤 -STAGE = 0 +STAGE = 1 GRID = [float(at) for at in range(0, 201, 20)] SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") 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_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_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_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), "색 토큰 없음" From 3abd2bdfd49f0adb82fd8deebfaab366c7c0eb44 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 15:40:38 +0900 Subject: [PATCH 10/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20322-2=20M02=20=ED=91=9C=20=ED=99=94=EB=A9=B4=20=E2=80=94?= =?UTF-8?q?=20=ED=83=AD=20=EC=85=8B(=ED=95=9C=EB=88=88=20=ED=91=9C=20?= =?UTF-8?q?=C2=B7=20=EC=A2=85=EB=A5=98=EB=B3=84=20=C2=B7=20=EB=B6=80?= =?UTF-8?q?=ED=92=88)=20=C2=B7=20=EB=B0=98=EC=98=81=20=C2=B7=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=C2=B7=20=EC=95=8C=EC=95=BD=20=EC=83=89=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20+=20enabled=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=C2=B7=20=EA=B2=80=EC=82=AC=20=EC=A7=9D(?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=3D=20=EC=84=9C=EB=B2=84=20=C2=B7=201-3=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=C2=B7=20=EB=A7=8C=EB=93=A0=20=EB=92=A4=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95)=20=C2=B7=20=EB=AA=85=EC=84=B8=20=ED=8F=BC?= =?UTF-8?q?=20=EB=B3=B4=EC=9E=84=20=EB=94=B0=EB=A6=84=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로] - 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만 - 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만) - 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참 - 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만 - 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름 - 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번 - 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_structure_form.ts | 10 +- A00_Common/design/design_structure_spec.ts | 17 + B05_Profile/B05_Profile_Api_Structures.ts | 17 + .../B05_Profile_Structures_Repository.py | 11 + B05_Profile/B05_Profile_Structures_Router.py | 6 +- B05_Profile/B05_Profile_Structures_Schema.py | 2 + .../B05_Profile_UI_Drainage_Facility.ts | 17 +- B05_Profile/B05_Profile_UI_Page_Structures.ts | 3 + .../B05_Profile_UI_Structures_Fields.ts | 5 + B05_Profile/B05_Profile_UI_Structures_Spec.ts | 12 +- .../B06_Section_UI_Page_Structures_Panel.ts | 3 + .../StructOptions/M02_SO_Check.py | 169 +++++++++ .../StructOptions/M02_SO_Def.ts | 72 +++- .../StructOptions/M02_SO_Def_Check.ts | 192 ++++++++++ .../StructOptions/M02_SO_Def_Parts.ts | 73 ++++ .../StructOptions/M02_SO_Def_Reflect.ts | 176 ++++++++++ .../StructOptions/M02_SO_Def_Spec.ts | 62 +++- .../StructOptions/M02_SO_Def_Table.ts | 327 ++++++++++++++++++ .../StructOptions/M02_SO_Definition.py | 13 +- .../StructOptions/M02_SO_Style.css | 64 ++++ .../StructOptions/M02_SO_Text.ts | 42 +++ resources/tester/helper_322_0_golden.cjs | 9 + resources/tester/helper_322_2_check.cjs | 109 ++++++ resources/tester/test_322_0_golden.py | 13 +- resources/tester/test_322_2_check_mirror.py | 238 +++++++++++++ .../tester/test_m02_structure_def_screen.py | 7 +- 26 files changed, 1635 insertions(+), 34 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Check.py create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts create mode 100644 resources/tester/helper_322_2_check.cjs create mode 100644 resources/tester/test_322_2_check_mirror.py 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_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/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index daf7bb4cc..570cc0018 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -141,6 +141,8 @@ export interface StructureListResponse { project_id: string; revision: number; structures: StructureInstance[]; + /** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */ + definition_version?: number | null; } export interface StructureSaveResponse { @@ -211,6 +213,21 @@ export async function dropStaleStructureTypes(): Promise { return true; } +const noticed = new Set(); + +/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */ +export function staleDefinitionNotice( + projectId: string, + saved: number | null | undefined, +): string | null { + if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) + return null; + const key = `${projectId}:${saved}:${typesVersion}`; + if (noticed.has(key)) return null; + noticed.add(key); + return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`; +} + const staleListeners = new Set<() => void>(); function checkStaleOnShow(): void { 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..2114b94ce 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, @@ -159,7 +160,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다. diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 8f4e42d95..cbdcb9202 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -305,3 +305,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_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 7a1b435ea..876d5f38d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -50,7 +50,7 @@ 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, @@ -607,6 +607,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 +617,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 +639,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); diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index e9080f96d..1dc736ccc 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -19,6 +19,7 @@ import { fetchStructures, dropStaleStructureTypes, fetchStructureTypes, + staleDefinitionNotice, saveStructures, readPendingStructures, writePendingStructures, @@ -369,6 +370,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; 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_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index a9ab220df..261b06514 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -18,7 +18,7 @@ import { import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier"; 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(); @@ -86,7 +86,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/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 379f7f71f..58fdee889 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -22,6 +22,7 @@ import { fetchStructures, dropStaleStructureTypes, fetchStructureTypes, + staleDefinitionNotice, readPendingStructures, pipesToStructureMarks, structureAnchorM, @@ -384,6 +385,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc 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; // 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며, diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py new file mode 100644 index 000000000..9a865d192 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -0,0 +1,169 @@ +"""구조물 표 검사(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 + +from typing import Any + +from common_util.common_util_structure_catalog import load_parts, resolve_catalog + +BRANCHES = ("횡단도", "카드이름", "형상", "3D") +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) + 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": + out.append(f"「{row['name']}」 형상 none — 횡단도도 none 이어야 함") + kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음 + 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"]), + ) + for branch, part_id in used: + part = parts[branch].get(part_id) + if part is None: + continue + if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]: + out.append( + f"「{row['name']}」 {branch} 「{part_id}」 — 자리꼴 {row['placement']} · " + f"정본 {row['owner']} 에 안 맞음" + ) + for key in part["읽는 칸"]: + if key in keys and key in hidden: + out.append( + f"「{row['name']}」 {branch} 「{part_id}」 이 읽는 칸 「{key}」 — " + "숨길 수 없음" + ) + 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 []} + for old in before.get("types") or []: + tid = old["type_id"] + t = now.get(tid) + if t is None: + out.append(f"종류 「{tid}」 — 지울 수 없음") + continue + fixed = ( + ("자리꼴", old.get("placement"), t.get("placement")), + ("정본", old.get("managed_by"), t.get("managed_by")), + ("군 글자", old.get("group"), t.get("group")), + ) + for what, a, b in fixed: + if a != b: + out.append(f"「{tid}」 {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 []): + out.append(f"「{tid}.{o['key']}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)") + 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 + return _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..5e7512293 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). * ========================================================================== */ @@ -28,6 +30,10 @@ 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 { catalogProblems, type CheckDef } from "./M02_SO_Def_Check"; +import { buildParts } from "./M02_SO_Def_Parts"; +import { setReflect, syncEnabled } from "./M02_SO_Def_Reflect"; +import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table"; import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; @@ -44,10 +50,12 @@ export interface DefScreenOptions { const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -/** 빈 묶음 걷어냄 — 그리기 중 만든 빈 그룹 목록 · 배치 · 종류 덧칸 */ +/** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */ function tidy(def: StructDef): StructDef { const out = clone(def); - for (const part of [out.그룹, out.배치, out.종류, out.표시] as Record[]) + 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; @@ -88,6 +96,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,6 +110,13 @@ 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; sync(); @@ -199,10 +216,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 +245,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 +258,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 +292,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 +300,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const paint = (): void => { paintKinds(); paintDetail(); + paintTabs(); paintFoot(); }; @@ -273,7 +318,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 +384,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_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts new file mode 100644 index 000000000..1c4583774 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -0,0 +1,192 @@ +/* ============================================================================= + * 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"; + +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; + }>; + }>; + 표시?: 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); + for (const t of doc.types ?? []) { + const row = rows.get(t.type_id); + if (!row) continue; + if (row.shape === "none" && row.station !== "none") + out.push(`「${row.name}」 형상 none — 횡단도도 none 이어야 함`); + const kept = hiddenIn(before ?? doc, t.type_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 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; + if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner)) + out.push( + `「${row.name}」 ${branch} 「${id}」 — 자리꼴 ${row.placement} · 정본 ${row.owner} 에 안 맞음`, + ); + for (const key of part["읽는 칸"]) + if (keys.has(key) && hidden.includes(key)) + out.push(`「${row.name}」 ${branch} 「${id}」 이 읽는 칸 「${key}」 — 숨길 수 없음`); + } + } + return out; +} + +/** 앞 판과 비교 — 만든 뒤 고정. */ +function fixedErrors(doc: CheckDef, before: CheckDef): string[] { + const out: string[] = []; + const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); + for (const old of before.types ?? []) { + const t = now.get(old.type_id); + if (!t) { + out.push(`종류 「${old.type_id}」 — 지울 수 없음`); + continue; + } + const fixed: Array<[string, unknown, unknown]> = [ + ["자리꼴", old.placement, t.placement], + ["정본", old.managed_by ?? null, t.managed_by ?? null], + ["군 글자", old.group, t.group], + ]; + for (const [what, a, b] of fixed) + if (a !== b) out.push(`「${old.type_id}」 ${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(`「${old.type_id}.${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; + return before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []; +} 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..21fc3467b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * 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 { PART_BOX, partsOff, setPartOn, 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 blocks = (Object.keys(PART_BOX) as Branch[]).flatMap((branch) => [ + ...head(branch), + el("table", { + className: "m02-sod__table", + attrs: { "data-sod-parts": branch }, + children: [ + el("thead", { + children: [ + el("tr", { + children: ["", "ColName", "PartRead", "PartFits", "PartUsers", "PartOn"].map((key) => + el("th", { text: key ? to(key as "ColName") : "" }), + ), + }), + ], + }), + 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: [ + el("td", { children: [el("small", { className: "m02-opts__key", text: id })] }), + el("td", { text: part.이름 }), + el("td", { text: part["읽는 칸"].join(" · ") || "—" }), + el("td", { text: `${part.자리꼴.join(" · ")} / ${part.정본.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..b89de8151 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts @@ -0,0 +1,176 @@ +/* ============================================================================= + * 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 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 }).처음값; + +/** 칸 → 표 줄 열쇠. */ +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]; +} 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..0cdb8c753 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts @@ -0,0 +1,327 @@ +/* ============================================================================= + * 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 { + isSet, + PART_BOX, + 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")], +]; +/** 갈래 고르기 — 없음(카드 이름은 종류 이름) + 부품 상자 차례. */ +const partPairs = (branch: Exclude): Pairs => [ + [ + branch === "카드이름" ? "name" : "none", + branch === "카드이름" ? to("TitleName") : to("OffNone"), + ], + ...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, `${id} · ${part.이름}`] as const), +]; + +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}` }, + ); + 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 ?? ""); + return mark( + isSet(def, type.type_id, "형상칸"), + textBox( + form, + () => undefined, + () => undefined, + { + edit, + attr: "data-sod-shape-form", + place: to("WallForm"), + max: 20, + }, + ), + checkBox( + to("Weep"), + row.shapeOpts.weep === true, + (v) => (setShapeOpt(def, type.type_id, "weep", v), changed()), + { edit, attr: "data-sod-shape-weep" }, + ), + ); +} + +/** 형상칸 글은 떠날 때 씀(초점 안 끊김). */ +function bindForm(node: HTMLElement, ctx: CellCtx): void { + const box = node.querySelector("[data-sod-shape-form]"); + box?.addEventListener("change", () => { + setShapeOpt(ctx.def, ctx.type.type_id, "form", box.value.trim()); + ctx.changed(); + }); +} + +/** 알약 색(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): HTMLElement => + el("span", { + className: "m02-sod__fixed", + 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 }; + const opts = shapeOptsCell(ctx); + bindForm(opts, ctx); + return [ + [to("ColList"), reflectCell(ctx, "목록")], + [to("ColPlacement"), fixed(row.placement)], + [to("ColOwner"), fixed(row.owner)], + [to("ColStation"), reflectCell(ctx, "횡단도")], + [to("ColTitle"), reflectCell(ctx, "카드이름")], + [to("ColShape"), reflectCell(ctx, "형상")], + [to("ColShapeOpts"), opts], + [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", { + children: [ + el("small", { className: "m02-opts__key", text: type.type_id }), + ...(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..4f475b329 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 = ("줄", "칸", "너비") @@ -171,8 +173,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 +196,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 +209,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) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 233ecdc49..726c63bc9 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -368,3 +368,67 @@ 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); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index d56251b7b..7ebfdd906 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -125,6 +125,48 @@ 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"], + 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/resources/tester/helper_322_0_golden.cjs b/resources/tester/helper_322_0_golden.cjs index 39c462d16..ec272f756 100644 --- a/resources/tester/helper_322_0_golden.cjs +++ b/resources/tester/helper_322_0_golden.cjs @@ -223,6 +223,7 @@ const { fetchStructureTypes, onStaleStructureTypes, pipesToStructureMarks, + staleDefinitionNotice, writePendingStructures, } = loadTs(B05("B05_Profile_Api_Structures")); const { loadSectionDetail } = loadTs(B06("B06_Section_Section_Store")); @@ -322,6 +323,14 @@ async function main() { 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, diff --git a/resources/tester/helper_322_2_check.cjs b/resources/tester/helper_322_2_check.cjs new file mode 100644 index 000000000..710dcf345 --- /dev/null +++ b/resources/tester/helper_322_2_check.cjs @@ -0,0 +1,109 @@ +/* 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.부품); + +// 명세 폼 숨김 — 저장 · 갈래 키가 모두 숨김인 칸 · 키 있는 칸이 모두 숨긴 상자 +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/test_322_0_golden.py b/resources/tester/test_322_0_golden.py index 69948eccf..9b5cabd9e 100644 --- a/resources/tester/test_322_0_golden.py +++ b/resources/tester/test_322_0_golden.py @@ -182,7 +182,7 @@ def _run(work: Path) -> 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"]} + return {"samples": rows, "pick": ts["pick"], "version": ts["version"], "notice": ts["notice"]} def test_type_cache_follows_definition_version(result: dict) -> None: @@ -196,6 +196,17 @@ def test_type_cache_follows_definition_version(result: dict) -> None: } +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 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..d3557175c --- /dev/null +++ b/resources/tester/test_322_2_check_mirror.py @@ -0,0 +1,238 @@ +"""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) + 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"] == ["「개거(겉도랑)」 형상 none — 횡단도도 none 이어야 함"] + assert got["mismatch"] == [ + "「옹벽」 형상 「pipe_set」 — 자리꼴 interval · 정본 structures 에 안 맞음" + ] + assert got["hide_read"] == [ + "「배관」 형상 「pipe_set」 이 읽는 칸 「revet_foundation」 — 숨길 수 없음" + ] + assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음 + assert got["fixed"] == [ + # 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음 + "「BOX암거」 형상 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음", + "「BOX암거」 3D 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음", + "「기슭막이」 형상 「hidden_revet」 — 자리꼴 interval · 정본 structures 에 안 맞음", + "「pipe.pipe_diameter_mm」 선택지 값 — 고칠 수 없음(이름은 이름 표로)", + "「box_culvert」 자리꼴 — 만든 뒤 고정", + "「revetment」 정본 — 만든 뒤 고정", + "「mirror」 군 글자 — 만든 뒤 고정", + "종류 「etc」 — 지울 수 없음", + ] + # 형상 부품을 끄면 벽 다섯은 형상 none — 횡단도도 none 으로 바꿔야 저장됨 + assert len(got["parts_off"]) == 5 and all("횡단도도 none" in t for t in got["parts_off"]) + + +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_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_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py index c344372c5..6ca5e6242 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"}, }, { From 6c87207f76fffee5ba19b7b5b5731dd3f7aaf6a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 16:03:33 +0900 Subject: [PATCH 11/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20322-3=20=EC=B8=A1=EC=A0=90=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C(TS=20=C2=B7=20=ED=8C=8C=EC=9D=B4=EC=8D=AC?= =?UTF-8?q?=20=EC=A7=9D)=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20B05=20=EC=A2=85=EB=8B=A8=20=C2=B7=203D=20?= =?UTF-8?q?=EB=A7=89=EB=8C=80=20=C2=B7=20B06=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EB=9D=A0(=EB=AA=A8=EB=93=A0=20=EC=A2=85=EB=A5=98)=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=B8=A1=EC=A0=90=EC=9D=B4=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=9E=90=EB=A6=AC=20=EC=83=88=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=98=95?= =?UTF-8?q?=EC=83=81(219)=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EB=82=A1?= =?UTF-8?q?=EC=9D=8C=20=3D=20=EC=9E=90=EB=A6=AC=20+=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=C2=B7=20=EC=98=AE=EA=B8=B4=20=EA=B4=80=20=EA=B3=A0=EB=A6=84?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80=20=C2=B7=20=EB=B0=B0=EC=88=98=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=90=98=EB=A8=B9=EC=9E=84=20=EB=81=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측점 계획 planStations(TS) ↔ plan_stations(py): 관 · 구조물 + 구조물 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id · 빈칸 경고(형상 부품 필수 칸) · 같은 m 하나(이름 = 관 먼저 · 앞선 것) - 서버 resolve_extra_stations = 계획(같은 자리 한 번 · 옛 「마지막 이름」 걷음) · _stale_marks = 자리 + 이름(종류 · 관 공식 · 정의 판 이름) - 카드 목록(Section_Drafts) = 계획 이름 · 일반 측점 자리(regular)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 칩 · 관 그림 · 벽 · 포장(219 흠) - B05 종단 · 3D 측점 = 계획(design_structure_status.plannedIrregular) · 배수유역도로 관 자리 되먹임 끊음 - B06 종단 띠 = 계획(관만 → 모든 종류 · 관 구간도 넘김) - 고름: followMovedPipe — 고른 관을 옮기면 고름도 새 자리(B05 폼 · 종단 끌기 · B06 옮김) - 부품 상자 형상 「필수 칸」(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 · 기슭막이 높이) - 시험: test_322_3_plan_mirror(거울 · 규칙 · 서버 공급자 · 낡음) · test_322_3_flow(흐름 일곱 + 고름 따라감) · 272_1 = 같은 자리 한 번 · 318 도움 파일 표 모듈 한 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_selection.ts | 14 ++ A00_Common/design/design_structure_status.ts | 47 +++++ A00_Common/design/design_view_cross.ts | 15 +- B05_Profile/B05_Profile_Engine_Sections.py | 56 ++--- B05_Profile/B05_Profile_Structures_Schema.py | 5 - B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 4 +- B05_Profile/B05_Profile_UI_Page_Structures.ts | 113 ++-------- B06_Section/B06_Section_Router_Stations.py | 10 +- B06_Section/B06_Section_Section_Drafts.ts | 53 +++-- .../B06_Section_UI_Page_Structures_Panel.ts | 15 +- B06_Section/B06_Section_UI_Section_View.ts | 4 +- .../B06_Section_UI_Section_View_Profile.ts | 37 ++-- .../B06_Section_UI_Section_View_Types.ts | 3 + common_util/common_util_structure_catalog.py | 7 + common_util/common_util_structure_catalog.ts | 7 + common_util/common_util_structure_parts.json | 54 +++-- common_util/common_util_structure_station.py | 100 ++++++++- common_util/common_util_structure_station.ts | 114 ++++++++-- resources/tester/helper_318_cross_card.cjs | 2 + resources/tester/helper_322_3_flow.cjs | 196 ++++++++++++++++++ resources/tester/helper_322_3_plan.cjs | 43 ++++ resources/tester/test_272_1_station_list.py | 4 +- resources/tester/test_322_3_flow.py | 88 ++++++++ resources/tester/test_322_3_plan_mirror.py | 178 ++++++++++++++++ 24 files changed, 936 insertions(+), 233 deletions(-) create mode 100644 A00_Common/design/design_structure_status.ts create mode 100644 resources/tester/helper_322_3_flow.cjs create mode 100644 resources/tester/helper_322_3_plan.cjs create mode 100644 resources/tester/test_322_3_flow.py create mode 100644 resources/tester/test_322_3_plan_mirror.py diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index df7a528ef..3f83c04d1 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -188,3 +188,17 @@ export function selectDesign( dispatching = false; } } + +/** 관을 옮김 — 그 관을 골라 둔 고름이면 새 자리로(322-3 · 관 id = 자리 이름 `pipe-{누가거리}` 라 id 도 + * 새 자리로 · 옮겨도 고름 유지 · 부재키 그대로). 다른 것을 골랐으면 그대로. */ +export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void { + 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 : `pipe-${toM.toFixed(2)}` }, + { partKey: picked.partKey }, + ); +} diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts new file mode 100644 index 000000000..b61eec9e9 --- /dev/null +++ b/A00_Common/design/design_structure_status.ts @@ -0,0 +1,47 @@ +/* ============================================================================= + * A00_Common/design/design_structure_status.ts + * 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션) + * → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) → + * 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴). + * + * 구조물 id = 구조물은 structure_id · 관은 자리 이름 `pipe-{누가거리 2자리}`(파일 모양 그대로 · 저장 안 함). + * ========================================================================== */ + +import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; +import type { PlannedStation } from "../../common_util/common_util_structure_station"; + +/** 현황 — 종류마다 초안이 앞섬(초안 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(현황 · 알약 · 고름이 같은 값) — 자리 이름. */ +export const pipeId = (chainageM: number): string => `pipe-${chainageM.toFixed(2)}`; + +/** + * 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 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 } : {}), + }; + }); +} diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index fe849d939..419c455fc 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -17,6 +17,7 @@ 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 { PlanPipe } from "../../common_util/common_util_structure_station"; import { onStaleStructureTypes, type StructureInstance, @@ -136,7 +137,12 @@ export async function mountCrossView( // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). let structureMarksSink: - ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; + | (( + structures: StructureInstance[], + types: StructureType[], + pipes: ReadonlyArray, + ) => void) + | null = null; /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ let pipeReloadTimer = 0; let wallAdjustTimer = 0; @@ -144,8 +150,8 @@ 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). @@ -338,7 +344,8 @@ export async function mountCrossView( shared, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). - structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); + structureMarksSink = (structures, types, pipes) => + sectionView.setStructureMarks(structures, types, pipes); // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index ae637bc1b..b9e354689 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -25,7 +25,6 @@ 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 ( @@ -41,7 +40,8 @@ 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, catalog_row +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, @@ -259,52 +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 - # 표 횡단도(322-1) — span3(독립 기슭막이) = **시작·기준·종료** 세 측점(길수록 횡단도가 - # 여러 장) · anchor(그 밖 관 시설) = 기준 한 곳(관이 노선을 가로지르는 점형) · none = 없음. - station = (catalog_row(pipe.facility) or {}).get("station", "anchor") - if station == "none": - continue - if station == "span3": - 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 catalog_row(structure.type_id)["station"] == "span3": - 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_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index cbdcb9202..5d37d7eae 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -152,11 +152,6 @@ def is_station_planting_type(item: StructureType) -> bool: 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 = "구조물정의_이력" diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 74f3c0574..6b4e43dad 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_selection"; 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_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 1dc736ccc..ee5004ade 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -11,7 +11,6 @@ * 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다. * ========================================================================== */ -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"; @@ -25,25 +24,17 @@ import { 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 { listPick } from "../common_util/common_util_structure_catalog"; -import { - pipeStationMarks, - SAME_STATION_M, - structureStationMarks, - type StationMark, -} from "../common_util/common_util_structure_station"; +import { planStations } from "../common_util/common_util_structure_station"; +import { plannedIrregular } from "../A00_Common/design/design_structure_status"; /** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */ export interface PipeProjection { @@ -68,56 +59,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([]); @@ -142,11 +89,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) ──────────────────────────────────── @@ -162,41 +105,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(planPipes, ownStructures, planTypes), interval), + ); } /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). @@ -356,6 +280,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ]); deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1) deps.profilePanel().setStructureTypes(allTypes); + planTypes = allTypes; // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( allTypes.map((type) => [ @@ -410,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/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 c72eb4631..9eb580c75 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) · + * 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 + * 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3). * ========================================================================== */ import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -27,11 +28,11 @@ import { } from "@util/common_util_culvert_sets"; import { pipeStationMarks, - plantedChainages, + planStations, SAME_STATION_M, structureStationMarks, + type PlannedStation, } from "../common_util/common_util_structure_station"; -import { cardTitle } from "../common_util/common_util_structure_catalog"; import { previewStructureSections, type CrossSection, @@ -122,31 +123,15 @@ function bareOf(section: CrossSection): CrossSection { function decorate( fresh: CrossSection[], pipes: Pipes, - structures: StructureInstance[], + planned: readonly PlannedStation[], types: StructureType[], ): void { const registry = registryDefaults(types); - const byId = new Map(types.map((type) => [type.type_id, type])); - // 카드 이름 = 표 카드 이름(322-1 · 배관 = 관종 D관경 · 빈칸 = 그 종류 정의 기본값 · 그 밖 = 종류 이름) - const labels: Array<[number, string]> = [ - ...pipes.flatMap((pipe) => { - const facility = pipe.facility ?? "pipe"; - const label = cardTitle(facility, pipe.options, registry[facility]); - return pipeStationMarks(pipe).map((m): [number, string] => [m.chainage_m, label]); - }), - ...structures.flatMap((structure) => { - const type = byId.get(structure.type_id); - const label = cardTitle(structure.type_id, structure.options); - return structureStationMarks(structure, type).map((m): [number, string] => [ - m.chainage_m, - label, - ]); - }), - ]; 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) => @@ -192,7 +177,8 @@ export async function withDraftStations( ]).catch(() => null); 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( @@ -222,16 +208,25 @@ export async function withDraftStations( (section.grid ? replaced(section) : planted.every((at) => !near(at, section.chainage_m)) || replaced(section)); - stale = all.filter(isStale); - const bare = stale.filter((section) => section.grid).map(bareOf); - live = [...all.filter((section) => !isStale(section)), ...bare]; + // 일반 측점 자리(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 || bare.length) decorate([...fresh, ...bare], pipes, structures, types); + if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, planned, types); } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 58fdee889..d013f7967 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -45,6 +45,7 @@ import { coversChainage, NO_SELECTION, PIPE_MATCH_M, + followMovedPipe, readSelection, resolveSelection, selectDesign, @@ -97,7 +98,12 @@ 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; @@ -193,7 +199,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); @@ -507,6 +517,7 @@ 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); 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_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index a6ece2772..50611b2e8 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -47,6 +47,8 @@ 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"; /** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */ const MATCH_M = PIPE_MATCH_M; @@ -79,6 +81,7 @@ export interface SectionProfile { setMarks: ( structures: ReadonlyArray, types: ReadonlyArray, + pipes?: ReadonlyArray, ) => void; /** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */ redraw: () => void; @@ -88,25 +91,23 @@ 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)); + return plannedIrregular(planStations(pipeList, structures, types), intervalM); } /** @@ -141,6 +142,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 +182,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 +304,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/common_util/common_util_structure_catalog.py b/common_util/common_util_structure_catalog.py index 289156c93..c58199897 100644 --- a/common_util/common_util_structure_catalog.py +++ b/common_util/common_util_structure_catalog.py @@ -99,6 +99,13 @@ def type_ids_with(**match: str) -> set[str]: return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())} +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관경」(빈칸 = 그 종류 정의 기본값) · 그 밖 = 이름. diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index f26739e63..cd0a0a0c4 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -170,5 +170,12 @@ export function cardTitle( return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`; } +/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 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 index ceef3a46b..4eab9edb8 100644 --- a/common_util/common_util_structure_parts.json +++ b/common_util/common_util_structure_parts.json @@ -1,5 +1,5 @@ { - "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", + "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", "parts": { "횡단도": { "anchor": { @@ -48,7 +48,8 @@ "outlet_revet_height_m" ], "자리꼴": ["point"], - "정본": ["pipe_points"] + "정본": ["pipe_points"], + "필수 칸": ["pipe_diameter_mm"] }, "box": { "이름": "BOX 본체 · 날개벽", @@ -93,7 +94,8 @@ "이름": "물넘이 포장(파인 노면 · 도수로)", "읽는 칸": ["ford_width_m", "ford_height_m", "ford_slope_deg", "ford_slope_pct"], "자리꼴": ["point"], - "정본": ["pipe_points"] + "정본": ["pipe_points"], + "필수 칸": ["ford_height_m"] }, "hidden_revet": { "이름": "독립 기슭막이(관 숨김 벽)", @@ -110,7 +112,8 @@ "face_slope_ratio" ], "자리꼴": ["interval"], - "정본": ["pipe_points"] + "정본": ["pipe_points"], + "필수 칸": ["height_m"] }, "wall": { "이름": "벽(옹벽 · 흙막이 · 돌쌓기)", @@ -126,7 +129,8 @@ "face_slope_ratio" ], "자리꼴": ["interval"], - "정본": ["structures"] + "정본": ["structures"], + "필수 칸": ["height_m"] }, "berm": { "이름": "소단(사면 계단)", @@ -138,7 +142,8 @@ "이름": "맹암거", "읽는 칸": ["culvert_height_m", "culvert_slope_pct"], "자리꼴": ["point"], - "정본": ["structures"] + "정본": ["structures"], + "필수 칸": ["culvert_height_m"] }, "pavement": { "이름": "포장층", @@ -289,7 +294,10 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "콘크리트", "weep": true }, + "형상칸": { + "form": "콘크리트", + "weep": true + }, "3D": "wall" }, "masonry_wet": { @@ -297,7 +305,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "돌쌓기(찰)" }, + "형상칸": { + "form": "돌쌓기(찰)" + }, "3D": "wall" }, "masonry_dry": { @@ -305,7 +315,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "돌쌓기(메)" }, + "형상칸": { + "form": "돌쌓기(메)" + }, "3D": "wall" }, "soil_guard": { @@ -313,7 +325,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "통나무·목재틀" }, + "형상칸": { + "form": "통나무·목재틀" + }, "3D": "wall" }, "boulder_masonry": { @@ -321,7 +335,9 @@ "횡단도": "span3", "카드이름": "name", "형상": "wall", - "형상칸": { "form": "돌쌓기(메)" }, + "형상칸": { + "form": "돌쌓기(메)" + }, "3D": "wall" }, "berm": { @@ -395,7 +411,13 @@ "형상": "none", "3D": "none" }, - "curb": { "목록": "both", "횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none" }, + "curb": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + }, "hazard_sign": { "목록": "both", "횡단도": "none", @@ -480,6 +502,12 @@ "형상": "pavement", "3D": "surface" }, - "etc": { "목록": "both", "횡단도": "none", "카드이름": "name", "형상": "none", "3D": "none" } + "etc": { + "목록": "both", + "횡단도": "none", + "카드이름": "name", + "형상": "none", + "3D": "none" + } } } diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 63d2b8622..de0a50429 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,100 @@ 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) ── + +#: 같은 측점으로 볼 거리(m) — TS `SAME_STATION_M` 짝 +SAME_STATION_M = 0.005 + + +def _empty(value: object) -> bool: + return value is None or value == "" + + +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, shape_needs + + def blank(type_id: str, options: dict | None) -> list[str]: + """형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝).""" + return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))] + + 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 43bd4c06e..80c594972 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -11,6 +11,11 @@ * * 심는 측점 = 구조물 표(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) 짝. 거울 시험 = @@ -18,7 +23,7 @@ * 이 목록 하나를 씀. * ========================================================================== */ -import { catalogRow } from "./common_util_structure_catalog"; +import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catalog"; const EPS = 1e-6; @@ -133,21 +138,100 @@ export function structureStationMarks( 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 === ""; + +/** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */ +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/resources/tester/helper_318_cross_card.cjs b/resources/tester/helper_318_cross_card.cjs index 054576849..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`); diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs new file mode 100644 index 000000000..c4b15cf4e --- /dev/null +++ b/resources/tester/helper_322_3_flow.cjs @@ -0,0 +1,196 @@ +/* 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) => { + 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); + 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) — 고른 관이면 새 자리 · 다른 구조물 고름은 그대로 + const sel = load(file("A00_Common", "design", "design_selection.ts")); + sel.selectDesign("P", { chainageM: 61, structureId: "pipe-61.00" }); + sel.followMovedPipe("P", 61, 75); + const moved = sel.readSelection("P"); + sel.selectDesign("P", { chainageM: 101, structureId: "w1" }); + sel.followMovedPipe("P", 101, 120); + out.follow = [moved, sel.readSelection("P")]; + 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/test_272_1_station_list.py b/resources/tester/test_272_1_station_list.py index 61c6c61af..f7b7cc380 100644 --- a/resources/tester/test_272_1_station_list.py +++ b/resources/tester/test_272_1_station_list.py @@ -50,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, @@ -67,7 +67,7 @@ STRUCTURES = [ }, {"type_id": "evac_road", "placement": "point", "chainage_m": 720.0}, ] -EXPECTED = [95, 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_322_3_flow.py b/resources/tester/test_322_3_flow.py new file mode 100644 index 000000000..c16d9d59e --- /dev/null +++ b/resources/tester/test_322_3_flow.py @@ -0,0 +1,88 @@ +"""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-75.00") + 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, []], + ] 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 From 0da2fa07678b94d431ee9b69bb08c74822489970 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 16:29:30 +0900 Subject: [PATCH 12/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20322-2=20=EB=8B=A4=EB=93=AC=EA=B8=B0(=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B8=20=ED=99=94=EB=A9=B4=20=ED=99=95=EC=9D=B8)=20?= =?UTF-8?q?=E2=80=94=20=ED=99=94=EB=A9=B4=EC=97=90=20=EC=98=81=EC=96=B4=20?= =?UTF-8?q?=ED=82=A4=20=EC=95=88=20=EB=B3=B4=EC=9E=84=20=C2=B7=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EA=B8=80=20=ED=95=9C=EA=B5=AD=EC=96=B4(=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EC=A7=9D)=20=C2=B7?= =?UTF-8?q?=20=EB=B6=80=ED=92=88=20=ED=83=AD=20=EC=9D=BD=EB=8A=94=20?= =?UTF-8?q?=EC=B9=B8=20=3D=20=EC=A0=95=EC=9D=98=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=EB=B0=94=EA=BF=88=20=C2=B7=20=ED=95=9C?= =?UTF-8?q?=EB=88=88=20=ED=91=9C=20=ED=8F=AD=20=EC=A4=84=EC=9E=84=20=C2=B7?= =?UTF-8?q?=20=EB=B0=94=EA=BF=A8=EB=8B=A4=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EB=A9=B4=20[=EC=A0=80=EC=9E=A5]=20=EA=BA=BC=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자리꼴 점 · 구간 · 터 · 정본 관 지점 · 구조물(키는 툴팁) · 고르기 글 = 부품 짧은 이름(괄호 설명 · 키는 툴팁) · 횡단도 없음 = 「안 만듦」 · 「벽 스윕」 → 「벽」 · 종류 키는 툴팁 - 검사 글: 「형상이 「없음」이면 횡단도도 「안 만듦」」 · 부품 짧은 이름 · 칸 이름 · 종류 이름(M02_SO_Def_Check.ts = M02_SO_Check.py) - 부품 탭: 읽는 칸 = 그 부품을 쓰는 종류의 칸 이름(단위) · 표 줄바꿈(table-layout fixed) - 한눈 표 형상칸 「기본 형태」 = 그림 형태 고르기(REVET_FORMS) - [저장] 켬 = 저장할 꼴(tidy · 목록 짝 enabled)끼리 지금 판과 다를 때만(M02_SO_Def_Reflect.differs) - 시험: 322-2 검사 글 · 되돌림 견본 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../StructOptions/M02_SO_Check.py | 32 +++++--- .../StructOptions/M02_SO_Def.ts | 15 +--- .../StructOptions/M02_SO_Def_Check.ts | 25 ++++--- .../StructOptions/M02_SO_Def_Parts.ts | 47 ++++++++++-- .../StructOptions/M02_SO_Def_Reflect.ts | 40 +++++++++- .../StructOptions/M02_SO_Def_Table.ts | 74 ++++++++++--------- .../StructOptions/M02_SO_Style.css | 12 +++ .../StructOptions/M02_SO_Text.ts | 1 + common_util/common_util_structure_parts.json | 2 +- resources/tester/helper_322_2_check.cjs | 18 +++++ resources/tester/test_322_2_check_mirror.py | 30 ++++---- 11 files changed, 207 insertions(+), 89 deletions(-) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py index 9a865d192..a8a9c9b70 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Check.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -13,11 +13,22 @@ 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} @@ -99,7 +110,7 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] if row is None: continue if row["shape"] == "none" and row["station"] != "none": - out.append(f"「{row['name']}」 형상 none — 횡단도도 none 이어야 함") + out.append(f"「{row['name']}」 형상이 「없음」이면 횡단도도 「안 만듦」") kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음 hidden = [key for key in _hidden_in(doc, t["type_id"]) if key not in kept] # 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 @@ -114,19 +125,20 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] ("형상", 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} 「{part_id}」 — 자리꼴 {row['placement']} · " - f"정본 {row['owner']} 에 안 맞음" - ) + 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} 「{part_id}」 이 읽는 칸 「{key}」 — " + f"「{row['name']}」 {branch} 「{name}」 이 쓰는 칸 「{labels[key]}」 — " "숨길 수 없음" ) return out @@ -138,9 +150,10 @@ def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: now = {t["type_id"]: t for t in doc.get("types") or []} 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"종류 「{tid}」 — 지울 수 없음") + out.append(f"종류 「{name}」 — 지울 수 없음") continue fixed = ( ("자리꼴", old.get("placement"), t.get("placement")), @@ -149,14 +162,15 @@ def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: ) for what, a, b in fixed: if a != b: - out.append(f"「{tid}」 {what} — 만든 뒤 고정") + 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 []): - out.append(f"「{tid}.{o['key']}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)") + label = o.get("label") or o["key"] + out.append(f"「{name}」 「{label}」 선택지 값 — 고칠 수 없음(이름만 고침)") return out diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 5e7512293..2d5636210 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -32,7 +32,7 @@ import { buildSpecBlock } from "./M02_SO_Def_Spec"; import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; import { catalogProblems, type CheckDef } from "./M02_SO_Def_Check"; import { buildParts } from "./M02_SO_Def_Parts"; -import { setReflect, syncEnabled } from "./M02_SO_Def_Reflect"; +import { differs, setReflect, tidy } from "./M02_SO_Def_Reflect"; import { buildReflectBlock, buildTable } from "./M02_SO_Def_Table"; import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; @@ -50,17 +50,6 @@ export interface DefScreenOptions { const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -/** 저장할 꼴 — 목록 + enabled 함께(322-2) · 빈 묶음 걷어냄(그룹 목록 · 배치 · 종류 덧칸 · 표시 · 반영) */ -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; -} - /** 작은 단추 */ function smallButton( label: string, @@ -118,7 +107,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { const panes = el("div", { className: "m02-opts__panes", children: [kindList, detail] }); const changed = (): void => { - dirty = true; + dirty = differs(work, base); sync(); paint(); }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts index 1c4583774..f843d0031 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -17,6 +17,7 @@ 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 { @@ -52,6 +53,7 @@ export interface CheckDef extends CatalogDefinition { choices?: readonly string[]; default?: unknown; empty_means?: string | null; + label?: string; }>; }>; 표시?: Record>; @@ -123,7 +125,7 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { const row = rows.get(t.type_id); if (!row) continue; if (row.shape === "none" && row.station !== "none") - out.push(`「${row.name}」 형상 none — 횡단도도 none 이어야 함`); + out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`); const kept = hiddenIn(before ?? doc, t.type_id); // 앞 판 없음 = 새로 숨긴 것 없음 const hidden = hiddenIn(doc, t.type_id).filter((key) => !kept.includes(key)); // 숨기면 그림이 기본값을 씀 — 기본값도 비워 두는 뜻도 없는 칸만 못 숨김 @@ -132,6 +134,8 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { .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], @@ -141,13 +145,16 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { 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} 「${id}」 — 자리꼴 ${row.placement} · 정본 ${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} 「${id}」 이 읽는 칸 「${key}」 — 숨길 수 없음`); + out.push( + `「${row.name}」 ${branch} 「${name}」 이 쓰는 칸 「${labelOf(key)}」 — 숨길 수 없음`, + ); } } return out; @@ -159,8 +166,9 @@ function fixedErrors(doc: CheckDef, before: CheckDef): string[] { const now = new Map((doc.types ?? []).map((t) => [t.type_id, t])); for (const old of before.types ?? []) { const t = now.get(old.type_id); + const name = old.name ?? old.type_id; if (!t) { - out.push(`종류 「${old.type_id}」 — 지울 수 없음`); + out.push(`종류 「${name}」 — 지울 수 없음`); continue; } const fixed: Array<[string, unknown, unknown]> = [ @@ -168,8 +176,7 @@ function fixedErrors(doc: CheckDef, before: CheckDef): string[] { ["정본", old.managed_by ?? null, t.managed_by ?? null], ["군 글자", old.group, t.group], ]; - for (const [what, a, b] of fixed) - if (a !== b) out.push(`「${old.type_id}」 ${what} — 만든 뒤 고정`); + 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); @@ -177,7 +184,7 @@ function fixedErrors(doc: CheckDef, before: CheckDef): string[] { const a = [...(o.choices ?? [])].sort().join("\u0000"); const b = [...(n.choices ?? [])].sort().join("\u0000"); if (a !== b) - out.push(`「${old.type_id}.${o.key}」 선택지 값 — 고칠 수 없음(이름은 이름 표로)`); + out.push(`「${name}」 「${o.label ?? o.key}」 선택지 값 — 고칠 수 없음(이름만 고침)`); } } return out; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts index 21fc3467b..7bc028558 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Parts.ts @@ -7,7 +7,15 @@ import { el } from "@ui/ui_template_elements"; import type { StructDef } from "./M02_SO_Def_Model"; -import { PART_BOX, partsOff, setPartOn, usersOf } from "./M02_SO_Def_Reflect"; +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"; @@ -20,17 +28,26 @@ export function buildParts(opt: { }): 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", + 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"].map((key) => - el("th", { text: key ? to(key as "ColName") : "" }), + children: (["ColName", "PartRead", "PartFits", "PartUsers", "PartOn"] as const).map( + (key) => el("th", { text: to(key) }), ), }), ], @@ -44,10 +61,24 @@ export function buildParts(opt: { attrs: { "data-sod-part": key }, className: off[key] === false ? "is-off" : "", children: [ - el("td", { children: [el("small", { className: "m02-opts__key", text: id })] }), - el("td", { text: part.이름 }), - el("td", { text: part["읽는 칸"].join(" · ") || "—" }), - el("td", { text: `${part.자리꼴.join(" · ")} / ${part.정본.join(" · ")}` }), + // 영어 키는 툴팁만(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: [ diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts index b89de8151..295f569df 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Reflect.ts @@ -14,7 +14,7 @@ import { type CatalogDefinition, type CatalogRow, } from "../../common_util/common_util_structure_catalog"; -import type { StructDef } from "./M02_SO_Def_Model"; +import { clone, type StructDef } from "./M02_SO_Def_Model"; export type ReflectField = "목록" | "횡단도" | "카드이름" | "형상" | "3D"; type Reflect = Partial> & { 형상칸?: Record }; @@ -29,6 +29,11 @@ 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", @@ -174,3 +179,36 @@ export function setShown( } 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_Table.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts index 0cdb8c753..c5cd176b7 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Table.ts @@ -9,9 +9,13 @@ 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, @@ -28,14 +32,26 @@ const LIST_PAIRS = (): Pairs => [ ["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", - branch === "카드이름" ? to("TitleName") : to("OffNone"), - ], - ...Object.entries(PART_BOX[branch]).map(([id, part]) => [id, `${id} · ${part.이름}`] as const), + [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; @@ -58,6 +74,7 @@ function reflectCell(ctx: CellCtx, field: ReflectField): HTMLElement { }, { edit, attr: `data-sod-reflect-${field}` }, ); + tipOptions(box, field); return mark(isSet(def, type.type_id, field), box); } @@ -69,24 +86,22 @@ function mark(changed: boolean, ...children: HTMLElement[]): HTMLElement { }); } -/** 형상칸 — 벽(형상 wall)만: 기본 형태 글 · 물빼기. */ +/** 형상칸 — 벽(형상 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, "형상칸"), - textBox( - form, - () => undefined, - () => undefined, - { - edit, - attr: "data-sod-shape-form", - place: to("WallForm"), - max: 20, - }, - ), + box, checkBox( to("Weep"), row.shapeOpts.weep === true, @@ -96,15 +111,6 @@ function shapeOptsCell(ctx: CellCtx): HTMLElement { ); } -/** 형상칸 글은 떠날 때 씀(초점 안 끊김). */ -function bindForm(node: HTMLElement, ctx: CellCtx): void { - const box = node.querySelector("[data-sod-shape-form]"); - box?.addEventListener("change", () => { - setShapeOpt(ctx.def, ctx.type.type_id, "form", box.value.trim()); - ctx.changed(); - }); -} - /** 알약 색(style.color). */ function colorCell(ctx: CellCtx): HTMLElement { const box = el("input", { @@ -121,9 +127,11 @@ function colorCell(ctx: CellCtx): HTMLElement { return box; } -const fixed = (text: string): HTMLElement => +/** 자리꼴 · 정본 = 한국어 글 · 「고정」(키는 툴팁). */ +const fixed = (text: string, key: string): HTMLElement => el("span", { className: "m02-sod__fixed", + attrs: { title: key }, children: [el("span", { text }), createTag(to("Fixed"), "neutral")], }); @@ -137,16 +145,14 @@ export function reflectCells( const row = rowsOf(def).get(type.type_id); if (!row) return []; const ctx = { def, type, row, edit, changed }; - const opts = shapeOptsCell(ctx); - bindForm(opts, ctx); return [ [to("ColList"), reflectCell(ctx, "목록")], - [to("ColPlacement"), fixed(row.placement)], - [to("ColOwner"), fixed(row.owner)], + [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"), opts], + [to("ColShapeOpts"), shapeOptsCell(ctx)], [to("ColColor"), colorCell(ctx)], [to("Col3D"), reflectCell(ctx, "3D")], ]; @@ -232,8 +238,8 @@ export function buildTable(opt: { children: [ td(""), el("td", { + attrs: { title: type.type_id }, // 영어 키는 툴팁만(322-2 다듬기) children: [ - el("small", { className: "m02-opts__key", text: type.type_id }), ...(edit ? [ moveButton( diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index 726c63bc9..d654c0980 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -432,3 +432,15 @@ 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 7ebfdd906..097e3e226 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -149,6 +149,7 @@ const TEXT = { ListB06: ["B06 만", "B06 only"], ListHidden: ["안 보임", "Hidden"], OffNone: ["없음", "None"], + StationNone: ["안 만듦", "Not made"], TitleName: ["종류 이름", "Type name"], Default: ["기본", "Default"], Fixed: ["고정", "Fixed"], diff --git a/common_util/common_util_structure_parts.json b/common_util/common_util_structure_parts.json index 4eab9edb8..067c63f89 100644 --- a/common_util/common_util_structure_parts.json +++ b/common_util/common_util_structure_parts.json @@ -184,7 +184,7 @@ "정본": ["pipe_points"] }, "wall": { - "이름": "벽 스윕", + "이름": "벽", "읽는 칸": [], "자리꼴": ["interval"], "정본": ["pipe_points", "structures"] diff --git a/resources/tester/helper_322_2_check.cjs b/resources/tester/helper_322_2_check.cjs index 710dcf345..faf8f8b8a 100644 --- a/resources/tester/helper_322_2_check.cjs +++ b/resources/tester/helper_322_2_check.cjs @@ -88,6 +88,24 @@ out.partOff = [clone(plain.부품), reflect.rowsOf(plain).get("retaining_wall"). 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)) { diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py index d3557175c..e4380b2dc 100644 --- a/resources/tester/test_322_2_check_mirror.py +++ b/resources/tester/test_322_2_check_mirror.py @@ -123,27 +123,25 @@ def test_check_rules(ts: dict) -> None: "부품 「횡단도.span3」 — 참거짓", "부품 「끔」 — 없는 부품", ] - assert got["rule218"] == ["「개거(겉도랑)」 형상 none — 횡단도도 none 이어야 함"] - assert got["mismatch"] == [ - "「옹벽」 형상 「pipe_set」 — 자리꼴 interval · 정본 structures 에 안 맞음" - ] + assert got["rule218"] == ["「개거(겉도랑)」 형상이 「없음」이면 횡단도도 「안 만듦」"] + assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물 에 안 맞음"] assert got["hide_read"] == [ - "「배관」 형상 「pipe_set」 이 읽는 칸 「revet_foundation」 — 숨길 수 없음" + "「배관」 형상 「배관 세트」 이 쓰는 칸 「흙막이 기초」 — 숨길 수 없음" ] assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음 assert got["fixed"] == [ # 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음 - "「BOX암거」 형상 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음", - "「BOX암거」 3D 「box」 — 자리꼴 interval · 정본 pipe_points 에 안 맞음", - "「기슭막이」 형상 「hidden_revet」 — 자리꼴 interval · 정본 structures 에 안 맞음", - "「pipe.pipe_diameter_mm」 선택지 값 — 고칠 수 없음(이름은 이름 표로)", - "「box_culvert」 자리꼴 — 만든 뒤 고정", - "「revetment」 정본 — 만든 뒤 고정", - "「mirror」 군 글자 — 만든 뒤 고정", - "종류 「etc」 — 지울 수 없음", + "「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점 에 안 맞음", + "「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점 에 안 맞음", + "「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물 에 안 맞음", + "「배관」 「관경」 선택지 값 — 고칠 수 없음(이름만 고침)", + "「BOX암거」 자리꼴 — 만든 뒤 고정", + "「기슭막이」 정본 — 만든 뒤 고정", + "「반사경」 군 글자 — 만든 뒤 고정", + "종류 「기타」 — 지울 수 없음", ] # 형상 부품을 끄면 벽 다섯은 형상 none — 횡단도도 none 으로 바꿔야 저장됨 - assert len(got["parts_off"]) == 5 and all("횡단도도 none" in t for t in got["parts_off"]) + assert len(got["parts_off"]) == 5 and all("횡단도도 「안 만듦」" in t for t in got["parts_off"]) def test_reflect_edits(ts: dict) -> None: @@ -165,6 +163,10 @@ def test_reflect_edits(ts: dict) -> None: 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 From 3d8c161efa0f3a85f995196e1a6c7ef897e8fd6f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 17:02:22 +0900 Subject: [PATCH 13/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20322-3=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20B06=20=EC=9D=84=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EC=97=B4=EC=96=B4=EB=8F=84=20=EC=98=9B=20?= =?UTF-8?q?=EC=86=8C=EC=88=98=20=EA=B4=80=20=EC=9E=90=EB=A6=AC=20=3D=20?= =?UTF-8?q?=EC=A0=95=EC=88=98=20m=20=EC=B4=88=EC=95=88(B05=20`snapOldStati?= =?UTF-8?q?ons`=20=EC=99=80=20=EA=B0=99=EC=9D=80=20=EC=85=88=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20167)=20=C2=B7=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EA=B3=A0=20=ED=8E=B8=EC=A7=91=20=C2=B7=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=EA=B0=90=20=C2=B7=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=EA=B0=99=EC=9D=80=20=EA=B8=80=20=C2=B7?= =?UTF-8?q?=20=ED=88=AC=EC=98=81=20=EC=9D=B4=EC=9B=94=20=EB=81=9D=20=3D=20?= =?UTF-8?q?=EC=84=A0=20=EA=B8=B8=EC=9D=B4(=EA=B4=80=EC=9D=B4=20=EC=A0=84?= =?UTF-8?q?=EB=B6=80=200=20m=20=EB=A1=9C=20=EA=B0=80=EB=8D=98=20=ED=9D=A0)?= =?UTF-8?q?=20=C2=B7=20design=5Fview=5Fcross=20685=EC=A4=84=20=EC=95=88(32?= =?UTF-8?q?2-3=20=EB=84=98=EC=B9=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B06 구조물 목록 load: 관 초안 없고 저장분에 소수 자리 = `snapPlan` 으로 옮긴 초안(persistPipes) → 카드 · 측점 계획이 B05 를 먼저 연 것과 같음 · 정본은 [저장]에서(B08 사본 이름도 PUT 길) - 서버 관 읽기는 그대로(파일 = 측점) · `project_pipe_points` 끝 = 선 길이 - 시험: test_322_3_flow 견본(85.05 … 982.64) → 85 · 173 · 258 · 352 · 440 · 534 · 620 · 720 · 804 · 900 · 983 · 정수 저장분 = 새 미리 뜬 카드 0 · 소수 저장분 = 정수 자리로 · test_322_3_saved_snap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_view_cross.ts | 18 ++------ B05_Profile/B05_Profile_UI_Structures_Snap.ts | 17 +++++--- .../B06_Section_UI_Page_Structures_Panel.ts | 22 ++++++++++ common_util/common_util_drainage_pipes.py | 3 +- resources/tester/helper_322_3_flow.cjs | 43 +++++++++++++++++++ resources/tester/test_322_3_flow.py | 25 +++++++++++ resources/tester/test_322_3_saved_snap.py | 25 +++++++++++ 7 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 resources/tester/test_322_3_saved_snap.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 419c455fc..30eed5fd1 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -17,12 +17,7 @@ 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 { PlanPipe } from "../../common_util/common_util_structure_station"; -import { - onStaleStructureTypes, - type StructureInstance, - type StructureType, -} from "../../B05_Profile/B05_Profile_Api_Structures"; +import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { fetchSectionContext, getSections, @@ -136,13 +131,7 @@ export async function mountCrossView( ); // 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b). - let structureMarksSink: - | (( - structures: StructureInstance[], - types: StructureType[], - pipes: ReadonlyArray, - ) => void) - | null = null; + let structureMarksSink: Parameters[0]["onMarks"] | null = null; /** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */ let pipeReloadTimer = 0; let wallAdjustTimer = 0; @@ -344,8 +333,7 @@ export async function mountCrossView( shared, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). - structureMarksSink = (structures, types, pipes) => - sectionView.setStructureMarks(structures, types, pipes); + structureMarksSink = sectionView.setStructureMarks; // 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙. // 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로 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/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index d013f7967..ccb74a095 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -54,6 +54,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, @@ -427,6 +428,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(); @@ -500,6 +504,24 @@ 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(); + deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로 + } + snapNotice(plan.pipes.length, plan.skipped); + } + /** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */ function movePipeTo(fromChainageM: number, toChainageM: number): void { if (!deps.projectId) { diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 1b8454b1c..14c900656 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -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 diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index c4b15cf4e..ca2a6039d 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -68,6 +68,7 @@ function load(target) { 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( @@ -189,6 +190,48 @@ async function run(setup, detail) { sel.selectDesign("P", { chainageM: 101, structureId: "w1" }); sel.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)); diff --git a/resources/tester/test_322_3_flow.py b/resources/tester/test_322_3_flow.py index c16d9d59e..b902004e0 100644 --- a/resources/tester/test_322_3_flow.py +++ b/resources/tester/test_322_3_flow.py @@ -86,3 +86,28 @@ def test_remove_and_reopen(flows: dict) -> None: [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_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] From c45814f9105fc24cd7692aac101b631b82f105b8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 17:26:53 +0900 Subject: [PATCH 14/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20323-4=20=ED=98=95=EC=83=81=20=EC=96=B9=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C(TS=20=C2=B7=20=ED=8C=8C=EC=9D=B4=EC=8D=AC?= =?UTF-8?q?=20=EC=A7=9D)=20=E2=80=94=20=EC=A0=80=EC=9E=A5=EB=B6=84=20?= =?UTF-8?q?=C2=B7=20=EC=B4=88=EC=95=88=20=C2=B7=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=9C=AC=20=ED=9A=A1=EB=8B=A8=20=EA=B0=99=EC=9D=80=20=EA=B8=B8?= =?UTF-8?q?(=EC=A0=80=EC=9E=A5=20=EC=A0=84=20=EA=B7=B8=EB=A6=BC=20=3D=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EA=B7=B8=EB=A6=BC)=20=C2=B7?= =?UTF-8?q?=20=EB=B9=88=EC=B9=B8=20=EA=B2=BD=EA=B3=A0=20=ED=95=9C=20?= =?UTF-8?q?=EA=BC=B4(=EC=B9=B4=EB=93=9C=20=EC=B9=A9=20=E3=80=8C=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EC=B9=B8=20=EB=B9=88=EC=B9=B8=E3=80=8D)=20=C2=B7?= =?UTF-8?q?=203D=20=EC=A7=80=EB=AC=B8=20=3D=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=ED=86=B5=EC=A7=B8=20=C2=B7=20C=EA=B5=B0?= =?UTF-8?q?=20=EB=B2=BD=203D=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=20=EC=9B=90=EC=B2=9C=20=3D=20=ED=91=9C=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20berm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 짝 `common_util_structure_attach.ts` attachShapes ↔ `B06_Section_Engine_Attach.py` attach_shapes — 열쇠 걷고 관 세트 → 벽 → 포장 → 맹암거 → 빈칸 경고(측점 계획 자리 · 「옹벽 높이」 · 「배관 관경」 · 「맹암거 암거 높이」 · 「물넘이포장 월류 높이」) - 서버 `_read_section_detail` · 재생성 응답 = attach_saved_shapes 하나 · 화면 `withDraftShapes`(현황 = 초안 ?? 저장분 · 초안 없어지면 저장분으로 한 번) — decorate 관 세트 · withDraftPipeKinds · 맹암거 · 포장 · 벽 블록 걷음 · 구조물 초안 없을 때 미리 뜬 횡단에 포장 · 벽 · 맹암거 빠지던 것 · 반폭 넓힘 뒤 날 상세(초안 빠짐) 고침 - 파이썬 얹기 = 목록 함수(culvert_sets_from · attach_culvert_specs · wall_specs_from · attach_wall_specs · underdrains_from · attach_underdrain_specs) + 옛 파일 함수는 감쌈 - 3D 지문 = SHAPE_KEYS(`common_util_structure_shape_keys.ts`) + 조작값 통째(손 해시 걷음 · 벽 기초 · 단 · 포장 · 맹암거 빠짐) · C군 벽 = 기준 측점과 가장 가까운 측점에서만 시작 ~ 끝 스윕(`wallSweepAt`) - 소단 = 표 형상 berm(BERM_TYPE_ID 걷음 · 화면 isBermType · 서버 catalog_row) - 시험: test_323_4_attach_mirror(42종 + 빈칸 · 서버 = 화면 · 옛 자리 걷힘 · 벽 한 번 · 지문) · 전수 표 ★ 둘(물넘이포장 걸친 격자 100 = 서버와 같음 · STAGE 4) · 옛 도움 파일 = 측점 → 형상 차례 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_Corridor_Structures.ts | 19 +- ...B05_Profile_UI_Corridor_Structures_Hash.ts | 200 ++++++------------ B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Engine_Attach.py | 105 +++++++++ B06_Section/B06_Section_Engine_Culvert.py | 15 +- .../B06_Section_Engine_Structures_Wall.py | 98 +++++---- B06_Section/B06_Section_Engine_Tilt.py | 13 +- B06_Section/B06_Section_Router.py | 23 +- B06_Section/B06_Section_Section_Drafts.ts | 76 ++++--- B06_Section/B06_Section_Section_Store.ts | 151 +------------ .../B06_Section_UI_Cross_Card_Chrome.ts | 17 +- B06_Section/B06_Section_UI_Page_Common.ts | 15 +- B06_Section/B06_Section_UI_Page_Persist.ts | 7 +- ...06_Section_UI_Page_Structures_Selection.ts | 4 +- common_util/common_util_structure_attach.ts | 124 +++++++++++ common_util/common_util_structure_catalog.py | 5 + common_util/common_util_structure_catalog.ts | 4 + common_util/common_util_structure_parts.json | 3 +- .../common_util_structure_shape_keys.ts | 16 ++ common_util/common_util_structure_station.py | 13 +- common_util/common_util_structure_station.ts | 6 +- resources/tester/golden_322_structures.json | 46 ++++ resources/tester/helper_173_6_half_width.cjs | 4 +- .../tester/helper_273_2_preview_stations.cjs | 10 + .../tester/helper_294_7_ford_slab_pick.cjs | 2 +- .../tester/helper_317_kind_change_station.cjs | 10 +- resources/tester/helper_319_kind_swap.cjs | 12 +- resources/tester/helper_322_3_flow.cjs | 1 + resources/tester/helper_323_4_attach.cjs | 64 ++++++ resources/tester/test_312_empty_pipes.py | 10 +- resources/tester/test_314_stale_structure.py | 11 +- resources/tester/test_322_0_golden.py | 2 +- resources/tester/test_323_4_attach_mirror.py | 178 ++++++++++++++++ .../tester/test_b06_facility_default_notes.py | 3 +- 34 files changed, 832 insertions(+), 437 deletions(-) create mode 100644 B06_Section/B06_Section_Engine_Attach.py create mode 100644 common_util/common_util_structure_attach.ts create mode 100644 common_util/common_util_structure_shape_keys.ts create mode 100644 resources/tester/helper_323_4_attach.cjs create mode 100644 resources/tester/test_323_4_attach_mirror.py diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index f0845d294..47ee633c5 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 { @@ -286,7 +286,10 @@ export function buildCorridorStructures( // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). + // C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것) + const wallSweep = wallSweepAt(section, crossSections); const revetLayout = + wallSweep && ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) && builds(section.revetment?.type_id, "wall") ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) @@ -364,18 +367,10 @@ 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", - ); - } + for (const tier of revetLayout.tiers) + pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own"); } /** 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/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 a7477cb60..48e1f4b56 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -604,8 +604,13 @@ 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")): + for point in points: # 그릴 세트 = 구조물 표 형상(322-1 · TS `buildCulvertSets` 짝) shape = (catalog_row(point.facility) or {}).get("shape") if shape == "pipe_set": @@ -631,7 +636,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_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 2b7087c15..ad661e1df 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -66,59 +66,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]]: """구조물 정본에서 벽(표 형상 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 (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 + 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). @@ -142,7 +143,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 @@ -190,7 +191,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 @@ -209,6 +217,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 ef57abf16..fb0152f13 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -42,6 +42,11 @@ 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 (catalog_row(structure.type_id) or {}).get("shape") != "underdrain": @@ -85,7 +90,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_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 9eb580c75..633c9d7a9 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -8,8 +8,8 @@ * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. * * 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) · - * 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 - * 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 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"; @@ -21,11 +21,8 @@ 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, planStations, @@ -119,14 +116,8 @@ function bareOf(section: CrossSection): CrossSection { return copy; } -/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ -function decorate( - fresh: CrossSection[], - pipes: Pipes, - planned: readonly PlannedStation[], - types: StructureType[], -): void { - const registry = registryDefaults(types); +/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */ +function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void { for (const section of fresh) { section.preview = true; // 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저) @@ -134,18 +125,41 @@ function decorate( 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); } /** @@ -170,11 +184,7 @@ 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 planned = planStations(pipes, structures, types, registryDefaults(types)); @@ -226,7 +236,7 @@ export async function withDraftStations( live.every((section) => !near(section.chainage_m, at)), ); fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; - if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, planned, 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 0767293a2..a752b2bce 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -18,35 +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 { cardTitle, catalogRow } from "../common_util/common_util_structure_catalog"; 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"; @@ -61,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, @@ -75,109 +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[]), - ); - // 표 형상(322-1) — 「사용 중」 거르기 없음(꺼진 종류도 그 그림대로 · 쇄석 포장층) - const types = await fetchStructureTypes(true).catch(() => []); - const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape); - // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) - const paveTypes = new Set(shaped("pavement").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), - ); - } - // 벽 = 표 형상 wall(소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 자리에 안 얹음 · 계획서 3-9) - const names = new Map(shaped("wall").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 SHAPE_KEY: Record = { - box: "box", - ford: "ford", - ford_pavement: "ford_pavement", -}; -const shapeOf = (facility: string | null | undefined): string => - catalogRow(facility ?? "pipe")?.shape ?? "pipe_set"; -const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => - SHAPE_KEY[shapeOf(facility)] ?? "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]) => - shapeOf(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) === (shapeOf(pipe.facility) === "hidden_revet")); - 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 shapeOf(pipe.facility) === "ford_pavement"; - return !here.some(([key, spec]) => fits(pipe, key, spec)); - }); - if (!stale.length) return; - const types = await fetchStructureTypes(true).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]; - } - // 카드 제목 구조물 이름 = 표 카드 이름(서버 `pipe_station_label` 짝 · 배관 = 관종 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"; - section.structure = cardTitle(facility, pipe.options, registry[facility]); - } - attachCulvertSets(sections, buildCulvertSets(stale, registry)); -} - /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * @@ -225,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_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_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..152a6fb81 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; 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/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 index c58199897..3ed7ea502 100644 --- a/common_util/common_util_structure_catalog.py +++ b/common_util/common_util_structure_catalog.py @@ -99,6 +99,11 @@ def type_ids_with(**match: str) -> set[str]: 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) diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index cd0a0a0c4..9c0dfccd3 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -171,6 +171,10 @@ export function cardTitle( } /** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ +/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */ +export const fieldName = (key: string): string | undefined => + (PARTS as unknown as { "칸 이름"?: Record })["칸 이름"]?.[key]; + export function shapeNeeds(typeId: string): readonly string[] { const shape = catalogRow(typeId)?.shape ?? "none"; const box = (PARTS as unknown as Parts).parts.형상 as Record; diff --git a/common_util/common_util_structure_parts.json b/common_util/common_util_structure_parts.json index 067c63f89..9b3cbd800 100644 --- a/common_util/common_util_structure_parts.json +++ b/common_util/common_util_structure_parts.json @@ -1,5 +1,5 @@ { - "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", + "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 칸 이름 = 어느 종류 정의에도 없는 읽는 칸의 화면 글(빈칸 경고 · 323-4). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", "parts": { "횡단도": { "anchor": { @@ -197,6 +197,7 @@ } } }, + "칸 이름": { "ford_height_m": "월류 높이" }, "처음값": { "pipe": { "목록": "both", 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 de0a50429..898de975f 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -80,6 +80,13 @@ 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]]: @@ -99,11 +106,9 @@ def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list · 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상 부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌. """ - from common_util.common_util_structure_catalog import card_title, catalog_row, shape_needs + from common_util.common_util_structure_catalog import card_title, catalog_row - def blank(type_id: str, options: dict | None) -> list[str]: - """형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝).""" - return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))] + blank = blank_keys marks: list[dict[str, object]] = [] for pipe in pipes: diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 80c594972..9cec8d6b5 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -170,8 +170,10 @@ export interface PlannedStation { const empty = (value: unknown): boolean => value === undefined || value === null || value === ""; /** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */ -const blankKeys = (typeId: string, options: Record | null | undefined): string[] => - shapeNeeds(typeId).filter((key) => empty(options?.[key])); +export const blankKeys = ( + typeId: string, + options: Record | null | undefined, +): string[] => shapeNeeds(typeId).filter((key) => empty(options?.[key])); /** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션 * 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */ diff --git a/resources/tester/golden_322_structures.json b/resources/tester/golden_322_structures.json index c40adad68..05815ecc9 100644 --- a/resources/tester/golden_322_structures.json +++ b/resources/tester/golden_322_structures.json @@ -260,6 +260,52 @@ } }, "까닭": "★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": { 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_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 48c397c6f..7f3b15bce 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -56,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); @@ -86,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_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_317_kind_change_station.cjs b/resources/tester/helper_317_kind_change_station.cjs index 6b4a74352..8b17b7e77 100644 --- a/resources/tester/helper_317_kind_change_station.cjs +++ b/resources/tester/helper_317_kind_change_station.cjs @@ -43,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_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index 88bdd05af..d59153035 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -229,6 +229,10 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt 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": { @@ -246,7 +250,13 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt }, }, ); - const { withDraftStations, forgetDraftStations } = load(B06("B06_Section_Section_Drafts")); + 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: [ diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index ca2a6039d..cb9753b12 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -128,6 +128,7 @@ async function run(setup, detail) { drafts.forgetDraftStations("P"); route += 1; await drafts.withDraftStations(detail, "P", route); + await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4) return summary(detail); } 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/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_314_stale_structure.py b/resources/tester/test_314_stale_structure.py index 302515919..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 shapeOf(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_322_0_golden.py b/resources/tester/test_322_0_golden.py index 9b5cabd9e..545bbde74 100644 --- a/resources/tester/test_322_0_golden.py +++ b/resources/tester/test_322_0_golden.py @@ -36,7 +36,7 @@ ROOT = HERE.parent.parent GOLDEN = HERE / "golden_322_structures.json" FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json" #: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤 -STAGE = 1 +STAGE = 4 GRID = [float(at) for at in range(0, 201, 20)] SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") 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_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 From cb4bab7bf20a93ab51935a4fbb9f55014b945f2f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 18:06:13 +0900 Subject: [PATCH 15/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20323-5=20=EA=B3=A0=EB=A6=84=20=ED=95=9C=20=EC=B9=B8=20?= =?UTF-8?q?=E2=80=94=20=EA=B4=80=20=EC=84=B8=EC=85=98=20id(=EC=98=AE?= =?UTF-8?q?=EA=B2=A8=EB=8F=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=95=88=20=ED=95=A8)=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84=20=EA=B0=95=EC=A1=B0=20=ED=95=9C=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(pickLights=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20=C2=B7=203D=20=EA=B0=99=EC=9D=80=20=ED=95=A8?= =?UTF-8?q?=EC=88=98)=20=C2=B7=20=EA=B7=B8=EB=A6=B0=20=EA=B2=83=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=8B=A0=EC=9B=90=ED=91=9C(data-structure-id=20+?= =?UTF-8?q?=20data-part)=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B1=B0=EB=A6=AC=20=3D=20PIPE=5FMATCH=5FM=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=C2=B7=20322-0=20=EC=9D=B4=20=EA=B9=AC=20?= =?UTF-8?q?=EC=98=9B=20=EC=8B=9C=ED=97=98=20=EB=8F=84=EC=9B=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=C2=B7=20=EC=A4=84=20=EC=88=98=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds) - `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만 - 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐) - 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704) - 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/b_page_state.ts | 2 + A00_Common/design/design_page.ts | 2 + A00_Common/design/design_pick_match.ts | 53 ++++++++++ A00_Common/design/design_pick_profile.ts | 21 ++-- A00_Common/design/design_pipe_ids.ts | 97 +++++++++++++++++++ A00_Common/design/design_selection.ts | 14 --- A00_Common/design/design_structure_status.ts | 5 +- A00_Common/design/design_view_cross.ts | 3 +- A00_Common/design/design_view_profile.ts | 16 ++- B05_Profile/B05_Profile_Api_Structures.ts | 3 +- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 7 +- .../B05_Profile_UI_Corridor_Structures.ts | 5 + B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 2 +- B05_Profile/B05_Profile_UI_Page_Structures.ts | 9 +- .../B05_Profile_UI_Viewer_Structure_Pick.ts | 33 +++++-- B06_Section/B06_Section_UI_Cross_Box.ts | 3 + B06_Section/B06_Section_UI_Cross_Culvert.ts | 7 ++ B06_Section/B06_Section_UI_Cross_Ford.ts | 5 + .../B06_Section_UI_Cross_Ford_Pavement.ts | 5 +- B06_Section/B06_Section_UI_Cross_Revetment.ts | 5 + .../B06_Section_UI_Cross_Shape_Pick.ts | 23 ++--- .../B06_Section_UI_Page_Structures_Panel.ts | 8 +- .../B06_Section_UI_Section_View_Profile.ts | 4 +- resources/tester/helper_165_pavement_flow.cjs | 11 +++ resources/tester/helper_231_1_pave_reload.cjs | 11 +++ resources/tester/helper_232_1_3d_press.cjs | 7 +- .../tester/helper_233_1_profile_pick.cjs | 1 + resources/tester/helper_233_2_view_pick.cjs | 8 +- resources/tester/helper_233_3_release.cjs | 1 + resources/tester/helper_270_1_cross_map.cjs | 1 + .../tester/helper_286_1_ford_pavement.cjs | 8 ++ resources/tester/helper_322_3_flow.cjs | 9 +- resources/tester/helper_323_5_pick.cjs | 94 ++++++++++++++++++ resources/tester/test_165_pavement_flow.py | 4 +- resources/tester/test_307_pick_highlight.py | 13 ++- resources/tester/test_322_3_flow.py | 2 +- resources/tester/test_323_5_pick.py | 67 +++++++++++++ 37 files changed, 488 insertions(+), 81 deletions(-) create mode 100644 A00_Common/design/design_pick_match.ts create mode 100644 A00_Common/design/design_pipe_ids.ts create mode 100644 resources/tester/helper_323_5_pick.cjs create mode 100644 resources/tester/test_323_5_pick.py 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_selection.ts b/A00_Common/design/design_selection.ts index 3f83c04d1..df7a528ef 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -188,17 +188,3 @@ export function selectDesign( dispatching = false; } } - -/** 관을 옮김 — 그 관을 골라 둔 고름이면 새 자리로(322-3 · 관 id = 자리 이름 `pipe-{누가거리}` 라 id 도 - * 새 자리로 · 옮겨도 고름 유지 · 부재키 그대로). 다른 것을 골랐으면 그대로. */ -export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void { - 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 : `pipe-${toM.toFixed(2)}` }, - { partKey: picked.partKey }, - ); -} diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index b61eec9e9..1fc2a3709 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -4,7 +4,7 @@ * → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) → * 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴). * - * 구조물 id = 구조물은 structure_id · 관은 자리 이름 `pipe-{누가거리 2자리}`(파일 모양 그대로 · 저장 안 함). + * 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함). * ========================================================================== */ import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; @@ -18,9 +18,6 @@ export function structureStatus( return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] }; } -/** 관 id(현황 · 알약 · 고름이 같은 값) — 자리 이름. */ -export const pipeId = (chainageM: number): string => `pipe-${chainageM.toFixed(2)}`; - /** * 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` · * 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 = diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 30eed5fd1..0dae09d4a 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -18,6 +18,7 @@ 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 { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; +import { pipeIdAt } from "./design_pipe_ids"; import { fetchSectionContext, getSections, @@ -399,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(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index d3b44d3d0..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 { @@ -60,6 +59,7 @@ import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations, onStaleStructureTypes, + structureAnchorM, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; import { @@ -80,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, @@ -134,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); }; @@ -552,11 +551,8 @@ export async function mountProfileView( }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) - host.onDispose( - onStaleStructureTypes(() => { - if (host.visible() && !restoring) void bridge.load(); - }), - ); + const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load()); + host.onDispose(onStaleStructureTypes(reloadIfShown)); // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 570cc0018..8f1e945cd 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -15,6 +15,7 @@ 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 { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import { type DefinedRule, @@ -324,7 +325,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, 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 47ee633c5..00ffd48cc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -86,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]>; /** @@ -288,6 +290,7 @@ export function buildCorridorStructures( // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). // C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것) const wallSweep = wallSweepAt(section, crossSections); + const wallId = section.revetment?.structure_id; const revetLayout = wallSweep && ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) && @@ -369,8 +372,10 @@ export function buildCorridorStructures( if (revetLayout && wallSweep) { // 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다. + 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; } /** diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 6b4e43dad..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 { followMovedPipe, 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"; diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index ee5004ade..669192248 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -35,6 +35,7 @@ import { plantsStation } from "./B05_Profile_UI_Profile_Structures"; 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 { @@ -119,7 +120,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { if (!planTypes.length) return; const interval = deps.panel().values().stationInterval || 20; applyIrregularStations( - plannedIrregular(planStations(planPipes, ownStructures, planTypes), interval), + plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval), ); } @@ -138,9 +139,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) } /** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다. @@ -161,7 +160,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, 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_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_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 14a9abba0..74e608c56 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, @@ -90,6 +91,8 @@ export interface RevetmentLayout { heightM: number; /** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */ chainageM: number; + /** 벽 구조물 id — 그린 벽 신원표(323-5). */ + structureId: string | null; /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ @@ -315,6 +318,7 @@ export function computeRevetmentLayout( side, heightM: pureHeight, chainageM: Number(section.chainage_m) || 0, + structureId: spec.structure_id ?? null, requestedTiers, top: jt, walls, @@ -376,6 +380,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_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index ccb74a095..ea213c922 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -45,7 +45,6 @@ import { coversChainage, NO_SELECTION, PIPE_MATCH_M, - followMovedPipe, readSelection, resolveSelection, selectDesign, @@ -64,6 +63,7 @@ import { 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(종단) 화면에서 합니다."; @@ -348,7 +348,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?.(), }, @@ -650,7 +650,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 { @@ -666,7 +666,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_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 50611b2e8..b102316d9 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -49,6 +49,7 @@ import { } 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; @@ -107,7 +108,8 @@ function planMarkStations( options: mark.options, })); const structures = marks.filter((mark) => !isPipeMark(mark)); - return plannedIrregular(planStations(pipeList, structures, types), intervalM); + // 덮는 구조물 id = 관 세션 id(323-5 · 알약 id 와 같음) + return plannedIrregular(planStations(withPipeIds(pipeList), structures, types), intervalM); } /** diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 3eee4915c..3a82fb648 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -133,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, @@ -165,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_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 70d7a0fd8..836b3c577 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -152,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, @@ -184,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_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_286_1_ford_pavement.cjs b/resources/tester/helper_286_1_ford_pavement.cjs index 85d319513..2c1e1c3be 100644 --- a/resources/tester/helper_286_1_ford_pavement.cjs +++ b/resources/tester/helper_286_1_ford_pavement.cjs @@ -31,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_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index cb9753b12..0dddc8d54 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -183,13 +183,14 @@ async function run(setup, detail) { out.remove = await run({ savedStructures: [wall("retaining_wall")], draftStructures: [] }, kept); // 저장 뒤 다시 열기 — 초안 없음 = 서버 목록 그대로(빼 둔 옛 자리 돌아옴) out.reopen = await run({ savedStructures: [wall("retaining_wall")] }, kept); - // 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · 다른 구조물 고름은 그대로 + // 관을 옮겨도 고름 유지(322-3) — 고른 관이면 새 자리 · id = 관 세션 id 그대로(323-5) · 다른 고름은 그대로 const sel = load(file("A00_Common", "design", "design_selection.ts")); - sel.selectDesign("P", { chainageM: 61, structureId: "pipe-61.00" }); - sel.followMovedPipe("P", 61, 75); + 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" }); - sel.followMovedPipe("P", 101, 120); + 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")); 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/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_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_322_3_flow.py b/resources/tester/test_322_3_flow.py index b902004e0..7c85d1c5d 100644 --- a/resources/tester/test_322_3_flow.py +++ b/resources/tester/test_322_3_flow.py @@ -74,7 +74,7 @@ def test_cross_owner_switch_drops_pipe_drawing(flows: dict) -> None: def test_pick_follows_moved_pipe(flows: dict) -> None: moved, other = flows["follow"] - assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-75.00") + assert (moved["chainageM"], moved["structureId"]) == (75, "pipe-s1") # 세션 id 그대로(323-5) assert (other["chainageM"], other["structureId"]) == (101, "w1") # 구조물 고름은 그대로 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]] From 529baca3ff7f9c8e5e2e76dc9c2c0510e1df4441 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 18:21:41 +0900 Subject: [PATCH 16/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20323-6=20=EB=B0=94=EA=BF=88=20=ED=95=9C=20=EA=B8=B8=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=94=EB=80=9C=20=EC=8B=A0=ED=98=B8=20=ED=95=98?= =?UTF-8?q?=EB=82=98(=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=EB=8F=84=20=EC=84=A4=EA=B3=84=20=EA=B0=80=EA=B2=8C=20=C2=B7=20?= =?UTF-8?q?watchDrafts)=20=C2=B7=20=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88?= =?UTF-8?q?=20=ED=91=9C=20=EA=B7=9C=EC=B9=99(swapsInPlace=20=C2=B7=20E=20?= =?UTF-8?q?=C2=B7=20F=20=EC=A0=90=ED=98=95=20=E2=86=94=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=ED=98=95=20=3D=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EC=97=90=20=EC=83=88=EB=A1=9C)=20=C2=B7=20=EC=A1=B0=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=EC=97=86=EC=95=B0(?= =?UTF-8?q?=EC=95=8C=EB=A6=BC)=20=C2=B7=20=EA=B7=B8=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=EC=A0=95=EA=B0=92=20=EC=95=88=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A6=20=C2=B7=20replayDraftPipeOptions=20=EA=B1=B7?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 초안 쓰기 = useDesignStore().set("structures")(칸 그대로) → 관 초안과 같은 구독 · 횡단 보기 watchDrafts(구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만 · 옵션만은 폼 길이 그 카드만 · 카드 줌 · 팬 지킴) · 옛 되부름 onStructuresChanged 걷음 - 종류 바꿈 = 표(catalog) swapsInPlace — 정본 다름 · 구조물 자리꼴 다름(E · F) = 지우고 같은 기준 측점에 새로(구간 = 기본 길이) · 같으면 종류 키만 · 막히면 옛 기록 · 옛 종류 폼 + 알림(전엔 [수정] 조기 반환 → 조용히 되돌림 · 드롭다운만 새 종류) - syncApplied = 사용자가 조작한 세션이 있을 때만 깎음(카드 그리기가 자동 배치를 조정값으로 굳혀 [저장]으로 내보내던 것) - replayDraftPipeOptions 걷음 — 그림 자료는 형상 얹기(초안 현황) · 다단 조정값은 조정 세션이 정본 - 시험: test_323_6_change(신호 · 거르기 · 표 규칙 · 그리기 · 걷음) · test_319 점형 ↔ 구간형 · 막힘 알림 · 되얹기 시험 = 형상 얹기로 · 옛 기대 글 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_structure_status.ts | 33 ++++++++ A00_Common/design/design_view_cross.ts | 15 ++-- B05_Profile/B05_Profile_Api_Structures.ts | 7 +- .../B05_Profile_UI_Structures_Panel_Events.ts | 18 ++-- .../B06_Section_UI_Page_Pipe_Options.ts | 20 ----- .../B06_Section_UI_Page_Station_Controls.ts | 7 +- .../B06_Section_UI_Page_Structures_Panel.ts | 15 +--- common_util/common_util_structure_catalog.ts | 14 +++- resources/tester/helper_319_kind_swap.cjs | 68 ++++++++++++++- resources/tester/helper_323_6_change.cjs | 82 +++++++++++++++++++ .../tester/helper_b06_draft_pipe_replay.cjs | 52 ++++++------ resources/tester/test_298_add_reanalyze.py | 10 ++- resources/tester/test_313_clear_all.py | 2 +- resources/tester/test_319_kind_swap.py | 16 ++++ resources/tester/test_323_6_change.py | 79 ++++++++++++++++++ .../tester/test_b06_draft_pipe_replay.py | 10 ++- 16 files changed, 352 insertions(+), 96 deletions(-) create mode 100644 resources/tester/helper_323_6_change.cjs create mode 100644 resources/tester/test_323_6_change.py diff --git a/A00_Common/design/design_structure_status.ts b/A00_Common/design/design_structure_status.ts index 1fc2a3709..79203c190 100644 --- a/A00_Common/design/design_structure_status.ts +++ b/A00_Common/design/design_structure_status.ts @@ -9,6 +9,7 @@ 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( @@ -42,3 +43,35 @@ export function plannedIrregular( }; }); } + +/** + * 바뀜 신호 하나(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 0dae09d4a..dba4f7730 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -37,14 +37,13 @@ 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 { 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 { @@ -145,7 +144,6 @@ export async function mountCrossView( syncBermSpans(structures); }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06). - onStructuresChanged: () => void refreshDetailForStructures(), onPipesWritten: () => { window.clearTimeout(pipeReloadTimer); pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200); @@ -565,11 +563,10 @@ export async function mountCrossView( void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314) }); // 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0) - host.onDispose( - onStaleStructureTypes(() => { - if (host.visible()) void structuresPanel.load().then(restoreStructurePick); - }), - ); + 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); @@ -669,8 +666,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/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 8f1e945cd..ea2106c23 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -11,11 +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, @@ -362,11 +362,12 @@ 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); } /** diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 77aebce06..4b0209d90 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -18,7 +18,8 @@ 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"; /** 배선에 필요한 패널 내부 값 — 이름은 분리 전 지역변수와 같다. */ @@ -89,9 +90,10 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { : 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) return startNew(ctx.groupSelect.value, next?.type_id); // 빈칸 = 새로 놓기 - // 정본이 다른 종류(관 지점 ↔ 구조물 — 맹암거 · 개거 등)로 = 옛 것을 지워 「없음」을 거친 뒤 같은 - // 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 기본값 · 요구 217) · 옛 조정값은 318-4 대로 버림 - if (!!next.managed_by !== !!prev.managed_by) { + // 표 규칙(323-6 · `swapsInPlace`) — 정본이 다르거나(관 지점 ↔ 구조물) 구조물 자리꼴이 다르면(E · F 점형 ↔ + // 구간형) 옛 것을 지워 「없음」을 거친 뒤 같은 기준 측점에 새 종류를 놓고 고름(우클릭 추가 길 · 새 종류 + // 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림 + if (!swapsInPlace(prev.type_id, next.type_id)) { const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; if (record) { ctx.structures.splice(index, 1); @@ -135,8 +137,12 @@ 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"); + } } type TypeValues = { 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 ea213c922..435cc2c02 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -108,9 +108,6 @@ export interface B06StructuresPanelDeps { /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ applyPipeOptions?: (chainageM: number, patch: Record) => void; - /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** - * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ - onStructuresChanged?: () => void; /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ onPipesWritten?: () => void; } @@ -215,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 @@ -284,7 +280,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); showToast(PIPE_ADD_NOTICE, "success"); }, // 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다 @@ -305,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 [저장]과 같은 규칙). @@ -333,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) => { @@ -517,7 +509,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (plan.pipes.length) { pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); persistPipes(); - deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로 } snapNotice(plan.pipes.length, plan.skipped); } @@ -544,7 +535,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); // 새 자리 횡단을 미리 떠 카드로(273-2) showToast(PIPE_MOVE_NOTICE, "success"); } @@ -563,7 +553,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc persistPipes(); section.setPipeFacilities(pipeFacilities); pushMarks(); - deps.onStructuresChanged?.(); showToast(PIPE_REMOVE_NOTICE, "success"); } diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index 9c0dfccd3..d416affff 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -170,11 +170,23 @@ export function cardTitle( return `${value("pipe_kind") ?? ""} D${Math.trunc(Number(value("pipe_diameter_mm"))) || 0}`; } -/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ /** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 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; diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index d59153035..4242bf606 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -95,10 +95,14 @@ const out = {}; ], { "./B05_Profile_UI_Structures_Define": { asRoad: (record) => record }, - "@ui/ui_template_elements": { showConfirmDialog: async () => true, showToast: () => {} }, + "@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 = () => ({ @@ -109,8 +113,18 @@ const out = {}; }, }); const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } }); - const run = ({ structures = [], pipes = [], editing = null, pipeAt = null, next }) => { + // commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림) + const run = ({ + structures = [], + pipes = [], + editing = null, + pipeAt = null, + next, + commitOk = true, + }) => { + toasts.length = 0; const calls = { + loaded: [], removed: [], addAt: [], emit: 0, @@ -164,14 +178,19 @@ const out = {}; syncPlacementFields: () => {}, renderOptionFields: () => {}, syncFacilityForm: () => {}, - liveCommit: () => (calls.live += 1), + 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: (at, typeId) => calls.addAt.push([at, typeId]), interval: () => 20, }; bindStructuresEvents(ctx); ctx.typeSelect.listeners.change(); - return { ...calls, left: structures.map((s) => s.type_id) }; + 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"); @@ -219,6 +238,47 @@ const out = {}; next: "open_ditch", }); out.to_blank = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null }); + // 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리 + out.point_to_interval = run({ + structures: [{ structure_id: "m1", type_id: "mirror", placement: "point", chainage_m: 140 }], + editing: "m1", + next: "guardrail", + }); + out.interval_to_point = 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 = run({ + structures: [ + { + structure_id: "r1", + type_id: "refuge", + placement: "interval", + start_m: 100, + chainage_m: 105, + end_m: 120, + }, + ], + editing: "r1", + next: "work_yard", + }); + // 막힘 = 옛 기록 · 옛 종류 폼으로 되돌리고 알림(조용한 되돌림 없앰) + out.blocked = run({ + structures: [{ structure_id: "b1", type_id: "mirror", placement: "point", chainage_m: 30 }], + editing: "b1", + next: "curb", + commitOk: false, + }); } // ── 2. 일반 측점 자리(grid) — 종류 바꿈 = 새로 뜬 횡단 · 다 빠짐 = 시설 뺀 사본(319-2) ────── 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_b06_draft_pipe_replay.cjs b/resources/tester/helper_b06_draft_pipe_replay.cjs index 50718cd1b..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` 머리와 같다. @@ -47,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/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_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_319_kind_swap.py b/resources/tester/test_319_kind_swap.py index 903fd7334..dc9fbdee1 100644 --- a/resources/tester/test_319_kind_swap.py +++ b/resources/tester/test_319_kind_swap.py @@ -75,6 +75,22 @@ def test_same_owner_switch_unchanged(result: dict) -> None: 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_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"] diff --git a/resources/tester/test_323_6_change.py b/resources/tester/test_323_6_change.py new file mode 100644 index 000000000..233ecc636 --- /dev/null +++ b/resources/tester/test_323_6_change.py @@ -0,0 +1,79 @@ +"""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_old_replay_removed() -> None: + assert "replayDraftPipeOptions" not in _src( + "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts" + ) 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") From 6744524669e9b33707c651210eeacc451e5b3d38 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 18:32:46 +0900 Subject: [PATCH 17/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20323-6=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0=20=EC=83=88=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EB=8A=94=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BF=88=20=3D=20=EC=83=88=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=EA=B0=80=20=EB=86=93=EC=9D=B8=20=EB=92=A4?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=98=9B=20=EA=B2=83=EC=9D=84=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80(=EB=AA=BB=20=EB=86=93=EC=9C=BC=EB=A9=B4=20=EC=98=9B?= =?UTF-8?q?=20=EA=B2=83=20=C2=B7=20=EC=98=9B=20=ED=8F=BC=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20+=20=EC=95=8C=EB=A6=BC)=20=C2=B7=20[?= =?UTF-8?q?=EC=B6=94=EA=B0=80]=20=EB=AC=B4=EB=B0=98=EC=9D=91=20=3D=20?= =?UTF-8?q?=EB=A7=89=ED=9E=8C=20=EC=B9=B8=20=EC=9D=B4=EB=A6=84=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 반사경 → 가드레일(길이 빈칸)이 옛 것을 먼저 지우고 새 것은 못 놓아 반사경이 사라지던 것 — 패널 addAt 이 놓였는지 돌려줌 · Events replaceAt = 먼저 놓고 확인 뒤 옛 것 지움 · 못 놓으면 옛 기록 폼(관은 관 폼) + 「반사경 그대로 — 가드레일(으)로 못 바꿈」 - commit 의 조용한 return 여섯 곳 = 막힌 칸으로 커서 + [추가] 때 「「길이」 칸이 비었거나 맞지 않아 못 놓음」(실시간 반영은 알림 없음) - 패널 줄 수 지킴(setter 한 줄로 · 697) - 시험: test_319 바꿈 막힘 둘(구조물 · 관) · 비동기 바꿈 · test_323_6 막힌 칸 알림 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_Structures_Panel.ts | 11 ++-- .../B05_Profile_UI_Structures_Panel_Commit.ts | 47 +++++++-------- .../B05_Profile_UI_Structures_Panel_Events.ts | 42 ++++++++++---- .../B05_Profile_UI_Structures_Panel_Types.ts | 4 +- resources/tester/helper_319_kind_swap.cjs | 58 ++++++++++++++----- resources/tester/test_319_kind_swap.py | 14 ++++- resources/tester/test_323_6_change.py | 8 +++ 7 files changed, 119 insertions(+), 65 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 55dbc07e3..05e25caf4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -508,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; @@ -670,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(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 7e3c6c64b..342f44e2c 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"; @@ -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,10 +313,11 @@ 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 @@ -323,24 +325,17 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis : (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 = { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 4b0209d90..a9e3ebaad 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -39,8 +39,10 @@ export interface StructuresEventContext extends StructuresCommitContext { commitPosition: () => void; liveCommit: () => void; loadForm: (target: StructureInstance | null) => void; - /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt`). */ - addAt: (chainageM: number, typeId: string) => void; + /** 우클릭 추가와 같은 길 — 그 측점에 그 종류를 놓고 고름(패널 `addAt` · 놓였으면 true). */ + addAt: (chainageM: number, typeId: string) => Promise | void; + /** 관 하나를 폼에 엶(패널 `selectPipeByChainage`). */ + selectPipe: (chainageM: number) => void; emit: () => void; optionRow: HTMLElement; } @@ -95,16 +97,7 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { // 기본값 · 구간 = 기본 길이 · 요구 217) · 옛 조정값은 318-4 대로 버림 if (!swapsInPlace(prev.type_id, next.type_id)) { const at = record ? structureAnchorM(record) : pipeItem!.chainage_m; - if (record) { - ctx.structures.splice(index, 1); - ctx.setEditingId(null); - ctx.emit(); - } else { - ctx.callbacks.onPipeRemove(at); - ctx.setSelectedPipeChainage(null); - } - forgetStationAdjusts(ctx.projectId, at, prev.type_id, next.type_id); - return ctx.addAt(at, next.type_id); + return void replaceAt(at, prev, next, record); } const key = record ? `s:${id}` : `p:${pipe}`; // 구조물은 정본 기록(안 보이는 상세 칸까지) · 관은 아직 옛 종류로 그려진 폼에서 읽음 @@ -145,6 +138,31 @@ export function bindStructuresEvents(ctx: StructuresEventContext): { } } + /** 지우고 같은 기준 측점에 새로 — 새 종류가 기본값으로 놓인 뒤에만 옛 것을 지움. 못 놓으면(빈 칸 · + * [추가] 가 막음) 옛 것 · 옛 폼 그대로 + 알림(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 = { options?: Record; summary_item?: unknown; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 73856b16f..9f99606ef 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -174,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; diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index 4242bf606..6eb4ee982 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -83,7 +83,7 @@ function makeLoader(real, stubs = {}) { const out = {}; // ── 1. 패널 종류 바꿈(319-2) — 정본 다른 종류 = 지우고 같은 측점 addAt ───────────── -{ +const part1 = (async () => { const load = makeLoader( [ B05("B05_Profile_UI_Structures_Panel_Events"), @@ -114,13 +114,15 @@ const out = {}; }); const fields = () => ({ station: el(), remainder: el(), wrap: { hidden: false } }); // commitOk = [수정] 반영이 받아 줌(패널은 기록을 새 것으로 갈아 끼움) · false = 막힘(필수 칸 등 · 323-6 알림) - const run = ({ + // 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 = { @@ -185,11 +187,13 @@ const out = {}; }, loadForm: (target) => calls.loaded.push(target?.type_id ?? null), emit: () => (calls.emit += 1), - addAt: (at, typeId) => calls.addAt.push([at, typeId]), + 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; @@ -199,52 +203,60 @@ const out = {}; JSON.stringify({ "80.00": { a: 1 }, "300.00": { b: 2 } }), ); out.pipe_to_underdrain = { - ...run({ pipes: [{ chainage_m: 80, facility: "pipe" }], pipeAt: 80, next: "cross_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 = { - ...run({ + ...(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 = { - ...run({ + ...(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 = run({ + out.pipe_to_box = await run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: "box_culvert", }); - out.underdrain_to_open_ditch = run({ + 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 = run({ pipes: [{ chainage_m: 200, facility: "pipe" }], pipeAt: 200, next: null }); + out.to_blank = await run({ + pipes: [{ chainage_m: 200, facility: "pipe" }], + pipeAt: 200, + next: null, + }); // 323-6 — E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로(구간 = 기본 길이는 `addAt`) · 같은 자리꼴 = 제자리 - out.point_to_interval = run({ + 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 = run({ + out.interval_to_point = await run({ structures: [ { structure_id: "g1", @@ -258,7 +270,7 @@ const out = {}; editing: "g1", next: "mirror", }); - out.interval_same = run({ + out.interval_same = await run({ structures: [ { structure_id: "r1", @@ -272,18 +284,32 @@ const out = {}; 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 = run({ + 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"), diff --git a/resources/tester/test_319_kind_swap.py b/resources/tester/test_319_kind_swap.py index dc9fbdee1..25e54e60b 100644 --- a/resources/tester/test_319_kind_swap.py +++ b/resources/tester/test_319_kind_swap.py @@ -46,7 +46,7 @@ def _src(*parts: str) -> str: 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"] == [None] + 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) @@ -55,7 +55,7 @@ def test_pipe_to_underdrain_removes_pipe_and_places_at_same_station(result: dict 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"] == [None] + assert back["left"] == [] and back["emit"] == 1 and back["editingSet"] == [] assert back["addAt"] == [[80, "pipe"]] assert back["kindreset"] is None @@ -85,6 +85,16 @@ def test_point_interval_switch_replaces_at_same_anchor(result: dict) -> None: 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"] diff --git a/resources/tester/test_323_6_change.py b/resources/tester/test_323_6_change.py index 233ecc636..e15175cc9 100644 --- a/resources/tester/test_323_6_change.py +++ b/resources/tester/test_323_6_change.py @@ -73,6 +73,14 @@ def test_drawing_does_not_create_adjusts() -> None: 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" From 81f7d98e6f1242852e7fbef738530be786345f8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 18:59:13 +0900 Subject: [PATCH 18/27] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C?= =?UTF-8?q?):=20323-7=20=EC=83=88=20=EC=A2=85=EB=A5=98=20=C2=B7=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=E2=80=94=20M02=20=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=EB=8D=94=ED=95=98=EA=B8=B0(=EB=B3=B8=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=C2=B7=20=EC=9D=BC=EA=B3=B1=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EB=B3=B8=EB=B3=B4=EA=B8=B0=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C)=20=C2=B7=20=EC=86=90=EC=BD=94=EB=94=A9=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=C2=B7=20=EB=AA=85=EC=84=B8=20=3D=20=EB=B3=B8?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=ED=82=A4=20=C2=B7=20=ED=8A=B9=EC=88=98=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=3D=20=EB=8F=99=EC=9E=91=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EC=A0=95=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=3D=20=ED=91=9C=20=C2=B7=20drawing=5Fviews=20=EA=B1=B7=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EC=98=9B=20=EC=9D=B4=EB=A6=84=20=EB=90=98=EC=A7=9A?= =?UTF-8?q?=EA=B8=B0=20=EA=B1=B7=EC=9D=8C(=EC=9E=90=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=A6=84)=20=C2=B7=20=ED=97=88=EC=9A=A9=20=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=ED=95=9C=20=EA=B3=B3?= =?UTF-8?q?=20=C2=B7=20=EB=B2=BD=20=ED=88=B4=ED=8C=81=20=3D=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02 종류별 탭 [종류 더하기] = 본보기(구조물 정본 원본) 복사 — 키 자동 · 이름 「본보기 2」 · 옵션 · 표시 · 그룹 · 배치 · 규칙 · 종류 덧칸 · 반영 그대로 · 종류.{새}.본보기 · 관 지점 종류 · 복사본은 본보기 밖 - 구조물 표 = 본보기 처음값 이어받음(resolveCatalog · resolve_catalog 짝 · 줄 칸 base) · baseType/base_type · specFor(명세) · 덧단 · 길이 전후 풀기 · 옹벽 추가 단 콘크리트 - 검사(화면 = 서버 짝) — 본보기 = 원본 · 같은 군 · 자리꼴 · 정본 · 만든 뒤 고정 · 복사본은 본보기가 숨긴 칸 이어받음 · 서버 덧칸 본보기 받음 - 특수 동작 = 동작 이름 — 바로 안 놓기 · 포장 나누기 = 포장_물넘이_겹침 · 월류 높이 기다림 = 월류단면 - 정본 목록 = 표 — 파이썬 관 지점 종류 = catalog owner · B05 시설 서브폼 = 표 형상 · 안 쓰는 손 목록 걷음 - drawing_views — 스키마에서 빼고 읽을 때 버림 · M02 [저장] 때 빠짐(코드는 마스터 안 고침) - 이관 = 지난 판 이름 대신 측점 계획 자리(planted)로 가름 · all_type_names 걷음 - 허용 거리 = common_util_structure_station.py 한 곳(SAME_STATION_M · EDGE_TOLERANCE_M · PIPE_MATCH_M) · TS 표는 station.ts 머리 - 시험: test_323_7_new_type(템플릿 · 일곱 갈래 · 그림 · 검사 짝 · 서버 읽기) · 322-0 유령 시험 = 자리 · 옛 도움 파일 동작 · 표 길 · 기대 글 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_structure_pipe.ts | 5 + B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 11 +- B05_Profile/B05_Profile_Api_Structures.ts | 1 - .../B05_Profile_Structures_Migration.py | 28 ++--- B05_Profile/B05_Profile_Structures_Router.py | 10 +- B05_Profile/B05_Profile_Structures_Schema.py | 29 ++--- .../B05_Profile_UI_Drainage_Facility.ts | 32 +++--- B05_Profile/B05_Profile_UI_Page_Helpers.ts | 10 -- .../B05_Profile_UI_Structures_Balloon.ts | 6 +- .../B05_Profile_UI_Structures_Define.ts | 5 +- .../B05_Profile_UI_Structures_Groups.ts | 8 +- B05_Profile/B05_Profile_UI_Structures_List.ts | 4 +- .../B05_Profile_UI_Structures_Panel_AddAt.ts | 12 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 10 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 9 +- .../B05_Profile_UI_Structures_Panel_Types.ts | 4 +- B05_Profile/B05_Profile_UI_Structures_Spec.ts | 5 +- B06_Section/B06_Section_Engine_Culvert.py | 5 +- .../B06_Section_Engine_Structures_Wall.py | 9 +- B06_Section/B06_Section_Engine_Tilt.py | 3 +- B06_Section/B06_Section_Router_Design.py | 5 +- B06_Section/B06_Section_UI_Cross_Revetment.ts | 7 +- ...B07_DesignDetail_Engine_Cad_CulvertNote.py | 3 +- .../StructOptions/M02_SO_Check.py | 33 +++++- .../StructOptions/M02_SO_Def.ts | 22 +++- .../StructOptions/M02_SO_Def_Add.ts | 45 ++++++++ .../StructOptions/M02_SO_Def_Check.ts | 36 +++++- .../StructOptions/M02_SO_Def_Model.ts | 6 +- .../StructOptions/M02_SO_Definition.py | 16 ++- .../StructOptions/M02_SO_Text.ts | 5 + common_util/common_util_drainage_pipes.py | 16 +-- common_util/common_util_structure_catalog.py | 10 +- common_util/common_util_structure_catalog.ts | 11 +- common_util/common_util_structure_pavement.py | 6 +- common_util/common_util_structure_station.py | 9 +- common_util/common_util_structure_station.ts | 5 + common_util/common_util_structure_walls.ts | 8 +- .../tester/helper_116_right_click_add.cjs | 12 +- resources/tester/helper_126_ford_fill.cjs | 20 +++- .../tester/helper_166_1_note_options.cjs | 2 + resources/tester/helper_323_7_new_type.cjs | 104 +++++++++++++++++ .../tester/helper_54_14_balloon_lines.cjs | 2 + resources/tester/helper_m02_so_def_spec.cjs | 9 +- resources/tester/test_178_3_slope_tiers.py | 3 +- resources/tester/test_322_0_ghost.py | 37 +++--- resources/tester/test_323_7_new_type.py | 106 ++++++++++++++++++ .../tester/test_b05_facility_options_merge.py | 15 ++- .../tester/test_m02_structure_def_screen.py | 5 +- 48 files changed, 595 insertions(+), 169 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Add.ts create mode 100644 resources/tester/helper_323_7_new_type.cjs create mode 100644 resources/tester/test_323_7_new_type.py 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/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 ea2106c23..b8665eb5f 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -79,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; /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 0d8d33c52..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, - all_type_names, 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,25 +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]: - """정본이 이미 관리하는 측점 이름 — 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). +def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool: + """정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령. - 지금 판 · 지난 판 정의의 **종류 이름 전부**(322-0) — 심는 측점 이름 · 관 지점 시설 이름만 - 보던 옛 규칙은 M02 에서 종류 이름을 바꾸면 옛 이름 측점을 못 알아보고 「기타」로 굳혔다 - (이름 바꿈 · 심는 규칙 바뀜 둘 다). + 자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기 + (지난 판 이름 전부 · 322-0)는 걷음 · 이름을 바꿔도 자리는 그대로라 옛 이름 측점도 알아봄. + 지금 판 종류 이름 · 관 라벨 꼴(관종 D관경)은 자리를 못 받은 부름(옛 확정분만)을 위해 그대로. """ - return all_type_names() - - -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) ) @@ -104,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`)이면 구조물 목록이 아니라 그 정본으로 간다 — @@ -117,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_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index 2114b94ce..a21bab4a4 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -43,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 @@ -193,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 5d37d7eae..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]: @@ -156,22 +163,6 @@ def is_station_planting_type(item: StructureType) -> bool: HISTORY_DIRNAME = "구조물정의_이력" -def all_type_names() -> set[str]: - """지금 판 + 지난 판 정의의 종류 이름 전부 — 이름을 바꾼 뒤 옛 이름 측점도 알아봄(322-0).""" - names = {item.name for item in load_structure_types()} - history = os.path.join(os.path.dirname(DEFINITION_PATH), HISTORY_DIRNAME) - for name in os.listdir(history) if os.path.isdir(history) else []: - if not (name.startswith("판_") and name.endswith(".json")): - continue - try: - with open(os.path.join(history, name), encoding="utf-8") as handle: - types = json.load(handle).get("types", []) - except (OSError, ValueError): - continue - names |= {str(t["name"]) for t in types if isinstance(t, dict) and t.get("name")} - return names - - def registry_schema_version() -> int: return int(_read_definition(*_stamp()).get("schema_version", 1)) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 876d5f38d..253be24d5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -57,6 +57,7 @@ import { 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; @@ -658,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_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_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 c964d5623..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[]; 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_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 342f44e2c..aabcc1501 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -22,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, @@ -117,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); @@ -320,9 +320,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis ); // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. // 범위 직접 입력형(노면공 · 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"); return stop(ctx.anchorFields.station, "기준 측점(시작 ~ 끝 안)"); @@ -357,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_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 9f99606ef..f47e9df0a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -20,7 +20,7 @@ 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 { specFor } from "../A00_Common/design/design_structure_pipe"; import { catalogRow } from "../common_util/common_util_structure_catalog"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. @@ -235,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_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index 261b06514..56a1661f3 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -15,8 +15,9 @@ 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 { hiddenOptionKeys, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; @@ -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`(패널 즉시 반영). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 48e1f4b56..4e895efe1 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -31,6 +31,7 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Schema import structure_type_map 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, @@ -39,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"} diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index ad661e1df..88607f211 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -24,12 +24,13 @@ 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 catalog_row +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 def _wall_form(type_id: str) -> str | None: @@ -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")), } diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index fb0152f13..6717e5d04 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -18,10 +18,11 @@ 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__) -_EDGE_TOLERANCE_M = 0.02 +_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M TiltRange = tuple[float, float, float | None] 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_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 74e608c56..6c36b1dfe 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -93,6 +93,8 @@ export interface RevetmentLayout { chainageM: number; /** 벽 구조물 id — 그린 벽 신원표(323-5). */ structureId: string | null; + /** 종류 이름(옹벽 · 흙막이 · 돌쌓기 …) — 툴팁 글(323-7 · 「독립 기슭막이」로 뜨던 것). */ + name: string; /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ @@ -319,6 +321,7 @@ export function computeRevetmentLayout( heightM: pureHeight, chainageM: Number(section.chainage_m) || 0, structureId: spec.structure_id ?? null, + name: spec.name || "벽", requestedTiers, top: jt, walls, @@ -352,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)}`, ); @@ -366,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 — 하부 지지 별도` 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 index a8a9c9b70..fd56bf337 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Check.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -111,7 +111,8 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] continue if row["shape"] == "none" and row["station"] != "none": out.append(f"「{row['name']}」 형상이 「없음」이면 횡단도도 「안 만듦」") - kept = _hidden_in(before or doc, t["type_id"]) # 앞 판 없음 = 새로 숨긴 것 없음 + # 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(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 = { @@ -144,10 +145,36 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] 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 @@ -159,6 +186,7 @@ def _fixed_errors(doc: dict[str, Any], before: dict[str, Any]) -> list[str]: ("자리꼴", 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: @@ -180,4 +208,5 @@ def catalog_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[s shape = _shape_errors(doc, known) if shape: return shape - return _row_errors(doc, before) + _fixed_errors(doc, before) if before else [] + 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 2d5636210..3d6479ad2 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -29,12 +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 { 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, textBox } from "./M02_SO_Def_Ui"; +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"; @@ -116,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( @@ -194,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", 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 index f843d0031..fa88aae0d 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -126,7 +126,8 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { if (!row) continue; if (row.shape === "none" && row.station !== "none") out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`); - const kept = hiddenIn(before ?? doc, t.type_id); // 앞 판 없음 = 새로 숨긴 것 없음 + // 앞 판 없음 = 새로 숨긴 것 없음 · 복사한 종류 = 본보기가 앞 판에서 숨긴 칸도 그대로(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( @@ -161,9 +162,35 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { } /** 앞 판과 비교 — 만든 뒤 고정. */ +/** 본보기(복사한 종류 · 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; @@ -175,6 +202,7 @@ function fixedErrors(doc: CheckDef, before: CheckDef): string[] { ["자리꼴", 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])); @@ -195,5 +223,9 @@ 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; - return before ? [...rowErrors(doc, before), ...fixedErrors(doc, before)] : []; + 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_Definition.py b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py index 4f475b329..157e0eadf 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Definition.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Definition.py @@ -66,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}" @@ -111,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 @@ -221,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_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 097e3e226..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: [ diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 14c900656..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 @@ -355,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_catalog.py b/common_util/common_util_structure_catalog.py index 3ed7ea502..403168711 100644 --- a/common_util/common_util_structure_catalog.py +++ b/common_util/common_util_structure_catalog.py @@ -41,7 +41,8 @@ def resolve_catalog( rows: dict[str, dict[str, Any]] = {} for order, item in enumerate(types): type_id = item["type_id"] - base = start.get(type_id) or {} + 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: @@ -72,6 +73,7 @@ def resolve_catalog( "shapeOpts": {**(base.get("형상칸") or {}), **(mine.get("형상칸") or {})}, "model3d": part("3D"), "color": (item.get("style") or {}).get("color") or None, + "base": root, } return rows @@ -94,6 +96,12 @@ 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())} diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index d416affff..c8c67f05b 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -34,6 +34,8 @@ export interface CatalogRow { /** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */ model3d: string; color: string | null; + /** 본보기 종류 키(323-7 · M02 종류 더하기) — 처음값 · 손코딩 동작을 이 종류에서 이어받음 · 원본이면 자기. */ + base: string; } type Branch = "횡단도" | "카드이름" | "형상" | "3D"; @@ -57,6 +59,8 @@ export interface CatalogDefinition { }>; 반영?: Record; 부품?: Record; + /** 종류 덧칸 — 본보기(복사한 종류의 원본 키 · 323-7). */ + 종류?: Record; } interface Parts { @@ -91,7 +95,8 @@ export function resolveCatalog( const rows = new Map(); types.forEach((item, typeOrder) => { const id = item.type_id; - const base = start[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]; @@ -122,6 +127,7 @@ export function resolveCatalog( shapeOpts: { ...(base.형상칸 ?? {}), ...(mine.형상칸 ?? {}) }, model3d: part("3D"), color: ("style" in item && item.style?.color) || null, + base: root, }); }); return rows; @@ -147,6 +153,9 @@ export function useCatalogDefinition(definition: CatalogDefinition | null): void /** 종류 한 줄 — 표에 없는 종류면 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") => diff --git a/common_util/common_util_structure_pavement.py b/common_util/common_util_structure_pavement.py index 11411dde6..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: diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index 898de975f..ef0370865 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -72,8 +72,15 @@ def snap_candidates( # ── 측점 계획 한 벌(322-3 · TS `planStations` 짝 · 거울 = test_322_3_plan_mirror.py) ── -#: 같은 측점으로 볼 거리(m) — TS `SAME_STATION_M` 짝 +# ── 같은 자리 거리 한 곳(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: diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 9cec8d6b5..9d14c770d 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -27,6 +27,11 @@ import { cardTitle, catalogRow, shapeNeeds } from "./common_util_structure_catal 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; diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index 1cdc8ca17..5edcbbd67 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -12,7 +12,7 @@ * ========================================================================== */ // `.js` = 짝 거울 시험이 tsc → Node ESM 으로 이 파일만 엮음(확장자 있어야 찾음 · vite 는 .ts 로 풂) -import { catalogRow } from "./common_util_structure_catalog.js"; +import { baseType, catalogRow } from "./common_util_structure_catalog.js"; /** 구조물 정본 한 건(필요한 칸만). `structures.json` 과 같은 이름을 쓴다. */ export interface WallStructureInput { @@ -89,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`]), })); } diff --git a/resources/tester/helper_116_right_click_add.cjs b/resources/tester/helper_116_right_click_add.cjs index 1853a63c6..8ee4931ea 100644 --- a/resources/tester/helper_116_right_click_add.cjs +++ b/resources/tester/helper_116_right_click_add.cjs @@ -49,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_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_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_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_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_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_322_0_ghost.py b/resources/tester/test_322_0_ghost.py index 68e3217df..b0e8749f0 100644 --- a/resources/tester/test_322_0_ghost.py +++ b/resources/tester/test_322_0_ghost.py @@ -1,7 +1,8 @@ -"""PLAN 322-0 ② — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 구조물로 되옮기지 않음. +"""PLAN 322-0 ② → 323-7 — 「기타」 유령 길 막기: 종류 이름을 바꾼 뒤에도 옛 이름 측점을 안 되옮김. -정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꾸고 앞 판을 `구조물정의_이력/판_N.json` 으로 둠 → -옛 이름 · 새 이름 측점 모두 이관 0건. 옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 종전대로 옮김. +정의 사본(임시 폴더 · 마스터 안 고침)에서 종류 이름을 바꿈 → 지금 관 · 구조물이 심는 자리(측점 +계획)의 측점은 옛 이름 · 새 이름 모두 이관 0건(323-7 · 자리로 가름 · 지난 판 이름 되짚기 걷음). +옛 꼴 자유 글(기성막이 · 대피로 폭 · 임의 이름)은 심는 자리가 아니면 종전대로 옮김. """ from __future__ import annotations @@ -37,23 +38,25 @@ def renamed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: return old -def _moved(label: str) -> int: - plan = migrate_irregular_stations([{"chainage_m": 120.0, "structure": label}]) +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_are_not_migrated(renamed: dict) -> None: +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) == 0, old_name - assert _moved(name) == 0, name + assert _moved(old_name, (120.0,)) == 0, old_name + assert _moved(name, (120.0,)) == 0, name + assert _moved(name) == 0, name # 지금 판 이름은 자리 없이도 -def test_every_type_name_of_any_version_is_blocked(renamed: dict) -> None: - names = {t["name"] for t in renamed["types"]} | set(RENAMED.values()) - assert names <= schema.all_type_names() - assert [name for name in sorted(names) if _moved(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: @@ -62,12 +65,4 @@ def test_legacy_free_text_still_migrates(renamed: dict) -> None: 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", "돌망태 보강")] - - -def test_master_history_names_are_read() -> None: - """실제 마스터 이력(판_1 ~)의 이름도 모음 — 이력 파일은 읽기만.""" - history = MASTER.parent / schema.HISTORY_DIRNAME - names = set() - for path in history.glob("판_*.json"): - names |= {t["name"] for t in json.loads(path.read_text(encoding="utf-8"))["types"]} - assert names <= schema.all_type_names() + assert _moved("돌망태 보강", (120.004,)) == 0 # 심는 자리면 글이 무엇이든 안 옮김 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_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_m02_structure_def_screen.py b/resources/tester/test_m02_structure_def_screen.py index 6ca5e6242..108ff9196 100644 --- a/resources/tester/test_m02_structure_def_screen.py +++ b/resources/tester/test_m02_structure_def_screen.py @@ -92,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 From b342e334111f5fc25b0c7b72a00203c9a764d324 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 19:16:17 +0900 Subject: [PATCH 19/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20222=20sub1?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B4=80=EB=A6=AC=20=ED=91=9C?= =?UTF-8?q?=20=EC=9E=AC=EC=84=A4=EA=B3=84=20=EC=A0=84=EC=B2=B4=20=3D=20?= =?UTF-8?q?=EB=B8=8C=EB=A0=88=EC=9D=B8=EC=9D=B4=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=EB=A1=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=B2=98=EB=9F=BC=20=EC=A0=84=EC=88=98=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=C2=B7=20=EB=AF=B8=EB=B0=98=EC=98=81=20=C2=B7=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EC=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 8e55dc7ab..553e0334b 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -250,3 +250,4 @@ 219. (원문 · 10-14) 내가 판단하기에는 현재의 드롭다운 메뉴가 결국 최신의 구조물 현황되어야해. 하지만 지금 b05,b06의 횡단도, 하이라이트, 횡단도 구조물 형상, 구조물 하이라이트, 종단 측점, 종단 알약, 3d측점 형상 들이 조건에따라 반영들이 바뀌고 있고 향후 유지보수나 구조물을 넣고 빼는데 계속 어려움이 발생되고 있어 정밀 분석해서 수정이 용이하고 오류가 없게 코드들의 구조를 잡아야 할것같은데 제안을 해줘. 대규모 리팩토링을 해서라도 잡고 넘어가는게 맞겠어. 일단 코드수정은 아니니 다이러그램을 이용해서 동작 플로우를 만들고 어떻게 정보들을 연결할지 현재와 비교 분석보고서를 만들고 코드반영할지 고민해보자 . 토큰 소모가 많아도 되니 꼼꼼히 분석해줘 220. (원문 · 10-14) 확인했고 별도의 구조물 관리 테이블을 운용하여 오류를 줄이자는 말이네. 여기에 추가로 검토해줘야 하는네용이 테이블관리하여 내가 제어했으면 좋겠어. 코드야 만들어두고 활성화할지 비활성화할지 어떤 메뉴들이 들어올지 내가 결정할 수있었으면 해. 그래서 m02에 보면 구조물의 옵션 텍스트를 변경할수있게 제어하는 메뉴가 있는데 이걸 통합하여 운영하는거야. 다시 정밀 점검 해봐. 완료되면 신규 계획서를 작성할거야 221. (원문 · 10-14) 이번 수정은 일단 랩탑 서브 1에 반영하고나서 내가 직접 확인후 병합여부를 결정할거야. 그러니 조심하고 너도 계획서의 전체 내용을 반영하고 브라우져 검증진행해줘. +222. (원문 · 10-14) 너가 브라우져로 접근해서 사용자처럼 전체 변경에 따른 미반영내용이 있는지 오류가 있는지 검증해줘. 오래걸려도 되니 하나씩 하나씩 지정해줘. From 56bc88bc47a13803464942cc2ba591444e38f331 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 20:59:24 +0900 Subject: [PATCH 20/27] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20223=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=91=9C=20=ED=9D=A0=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=A7=84=ED=96=89=20=C2=B7=20=EB=B3=B8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20=EB=A7=B9=EC=95=94=EA=B1=B0=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EC=A2=85=EB=A5=98=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C(=EB=B9=84=ED=99=9C=EC=84=B1)=20=C2=B7=20=EB=A7=B9?= =?UTF-8?q?=EC=95=94=EA=B1=B0=20=EA=B8=B0=EB=B3=B8=EA=B0=92=203=20m=20?= =?UTF-8?q?=C2=B7=203=20%=20=C2=B7=20=EC=A0=84=EC=97=AD=20=EA=B9=83=20?= =?UTF-8?q?=EC=8B=B1=ED=81=AC=20=C2=B7=20=EC=8B=B1=ED=81=AC=20=EB=92=A4=20?= =?UTF-8?q?M02=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=8B=A4=EB=AC=B4?= =?UTF-8?q?=EC=9E=90=EB=A3=8C(=EC=97=91=EC=85=80=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=ED=83=AD)=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=B3=84=EC=B9=AD=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=EC=98=B5=EC=85=98=20=C2=B7=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=83=81=EC=84=B8=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=EB=92=A4=20=EC=8B=A0=EA=B7=9C=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 553e0334b..1f52bbcf1 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -251,3 +251,16 @@ 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번 전역 깃 싱크를 필히 한후 추가 검토 및 반영 업무 진행할 것. From 35f861a51735104ec80f53308515f552d13e80c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 20:59:33 +0900 Subject: [PATCH 21/27] =?UTF-8?q?knowledge(=EC=8B=A4=EB=AC=B4=EB=AC=B8?= =?UTF-8?q?=EC=84=9C):=202026=20=EC=8B=A4=EB=AC=B4=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EB=AA=A8=EC=9D=8C=20xls=20=EB=84=B7=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C(=EB=B6=81=EB=B6=80=EC=B2=AD=20=EC=9E=84?= =?UTF-8?q?=EB=8F=84=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=EB=8B=A8=EA=B0=80=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EB=B0=A9=20=EA=B3=84=EB=A5=98+=EB=8C=90=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EB=B0=A9=EC=82=AC=EC=97=85=20=ED=91=9C=EC=A4=80=ED=92=88?= =?UTF-8?q?=EC=85=88(=EC=95=88)=20=C2=B7=20=EC=88=98=EB=AA=A9=EC=A7=84?= =?UTF-8?q?=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../2016_상반_산림청_사방사업표준품셈(안).xls | Bin 0 -> 720384 bytes .../2026_상반_산림청_수목진료.xls | Bin 0 -> 506880 bytes ...지방산림청_사방사업(계류+댐)_설계내역서.xls | Bin 0 -> 457216 bytes ...방산림청_임도사업_설계내역서(공통단가).xls | Bin 0 -> 667136 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 resources/knowledge/original/실무문서/2026_실무일위대가 모음/2016_상반_산림청_사방사업표준품셈(안).xls create mode 100644 resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026_상반_산림청_수목진료.xls create mode 100644 resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls create mode 100644 resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2016_상반_산림청_사방사업표준품셈(안).xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2016_상반_산림청_사방사업표준품셈(안).xls new file mode 100644 index 0000000000000000000000000000000000000000..d26ab3e5acb076e5ecde5e3f67b5272edd66427a GIT binary patch literal 720384 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≈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 A>Hq)$ literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls new file mode 100644 index 0000000000000000000000000000000000000000..ea8ac586c4b2ea425703f7d15a01ef100de4d6ef GIT binary patch literal 457216 zcmeFa4VYZldEa+egb2{4E$9O=Vngy!BBWiBSj^1Mewm<6iJ&Z7R6<&a0##&UMz%zi zqy*CI8cvi1X~~vNp@vG_R&HgRY(>{>2QWxLAwVoxEDT^5&5{BH0<>MKWm-B_TDi4b zvXomU%>Q@)^UgVUc4h#G^z-y-gTdLEx%bY!=i~kOzaQtE)8Bmii;q11qyMz%`S+8> zWyQaI{fgr9?D%SZ9*OU3i{dSv@Bi}k*I$pryZE4sc5$ID*u_Pc6@@PO*Z=-s*aQDU zbz=M|t29(^Rbk8->oqD@sQiG+yHu`J`5~1bR(YF>NT7JT$~#ovsiI*n)~Wmjl^;=A zuksgFepKbhR5qx*Tjf0}Kdw?!xk}}|D(_RdT4kfk`&B-mGO9AB@pzr zm0wbsQ~70;e@o?8RCcQTs>267jj|rJ^3wjjqj<|mJfHWA`SlO# zqPzYGDt~kSUF!4SoTsL{ypiYUzx~&r|Nqwm#pTz%Mf&=r$`4Cm|Cj_pF#?024D|2v z>n_vZckA>OmDA`6{k#0SRXTOqNbj#Z%ys#7TzAuoU!z_0@AB)o?v5WEkr2tBjpEV2 z%dg|Qzo=iYshmb9>EGqoaoyk7>33I7Q+xfp{JJZ2>T@dZsGLTNm8Vf_HJ!e^-|5xY zar!MPuUAfYZoXPKx5BHQ?o@4cS+$kF?lf$5*|3$TJKeIn?3R_MJDs|^?9`Q~J43Qs zL(;EmodI244(Q5XGtBz8TIA60*BsTq)z=~9SN^&q`d6c7JEvbOe!MnUD~jJ!qko_n zd3gD?Jw^MK_Ku|^henH4=O0}hlj0w#X$W}je7tpd>(e#q>Y8-;taN={dcCf1ud48A zT>5-gpRX^!U#o9TT{*75qRzO@)~DAWYaj9txCO6T@w^H*;9FD5=5w9fwDf&#`tgxA zw~1OzsSeY6N?oil^1>cYJ=WI8?>xcBqCK5{_}Iy}FTJ84Q~73Dv1?Ve2?3Gx?5586 zTSGPB+-ULg{JN#%?e)jTvg@XG&W)TBxSi{{u^g@F77(wM>cd(v+S`c zJv@Kp&PCs9LfGh9m4w6zNIVK46z4_a9ds;G4KCZWS8#>EywaSP1(JfVp@6&=mn{~f+>A_RQb0^zJ zA3Nz*JhU*X+I~VmXM`L2*+wDCku8V+^z_}Qvr!sEDVJ-M4t(j!xy9dkYVJ_|%vFBQ zSNDzUZuPy4+40U09Y6I4`)@mV@Z@)&94&U9d*MLQo?Cq3z_&FF(d=zPBVQ2q)NoG< za?LGnKi6~nvD6F*hFj+pz{t(2mmpZE4@%gq(hyzLix`%MzF(`}oYrexXY`EkJhAWS zq%NOX-m#?CuRnI`Y0!|h-XS-}p0VW?4P+%Jc=_H9-5n+gvY1q-x`QWmd`f4U zg5cdBwHKEU2K`gD8Qm3%p&8exOz1m=!9WKeHue0L@PGFvJe~gFWp=(+PimDnni0tF zUU+NgF1HH#x_hXm?>npKRFA?u>*PNzh6J<+UdI~M04-pQNo$u4-HBce^64wMN$9EbwU3T!au8Era?ht(o3O>I7 zioJK7sHgfwyEPUoUcSr9?BBeeagTUtMlX#ul~ezZ>tVe&<`G zF+SD&V5-`#Bm0hSksw|^NT1eAS-H9R?pe71A57d{b`%XxLZ+^7GwOyURiS4pCgv&H zS1-KMZXX>wQo8Q={PF$A+Y3iwvSVLAcH5ah*#EBi_Mx%fHuP(P+AuNRY-Q4M-T9;6 zdhDshj#L;9F-^gajJny8Mg}{gbsD;A0u46VT3w953-Njp;U5((FBnh#cEicJ#RDf^ zI&}B3(c-e#k=)3}=R5E8-XopfLo=Ans0g}%9`ul!ktfTeO_|9fJiBNk!RGdiE!X#s z7DrF>pmpa%R!wR2xjp>A@QjwN+$aXvz|SE< zXu4TdywKeY3VQKX6)Sn9QlhR<_*4ceLj+8S$BT@VKiSZiPd53}N{1)w`aHp(MqSXS zF2~^acafgdv+{sIsD{-t;{Qzj^oR_MwB{sug#3`Y9*anrgO5^}1cH>NY>R0l5aHBa!b_xl83HmCts%$=88t z<0`slT37Hs+j*bryw3zqgRWp(>D>)WK|fPN;4-W)tm-WDErqrD0OmR->xIa z+uwOY>ZH+nU#fU@Xw7E`zYvuP3wc~<07wm}HworgFOoX-q~zYzc)h6yJtEm&D;|9A zW3HV#{`%TnqjpX0iZyFxD!;Y(-Sy9`KDA39NAOeb!2kQxH+|$U|J;o?WflMP`Pi!0 zCuct_ox5+<9V3{Q*N?0(M)dtML5R1ivsX*gTp=6s2PI0srsF>>oXd6c{ps&J%fG+t zU+UN|=(Y9(+kXu2FQJI-Z@Hno;>+dV_m+QuSbs-~C(GabVR@t>FZGhHUsF7!9^lYh zM?NH-`gwV1?-on_d7kBoYl?Xqv*Njbp*T?1D$ZUuGPh^`(1GQKyzD*`^-X7fv{*Ip zj_+CZHubA0_I;xE_vuwH-m~fob+0<4F6IB8RiBc-U5N0nOO9Qw4>esASN?U0pzGwi zjx(d#x$2qsiW%RlE92-5<>&XT`i#J~=Y1mmnn(O(?<3Ob`1E2Kw<(HOUayIY;@{C4$3-GO{_@2ghdj&s z^jtsaICt%L{r{JbkBBbQKld~7tA71oh!y%Dzjna!O#_Z!G2l4T&-J;Xc0+A#;riev z#3w2!(fQ}&mrE{`$Lk|+=lY^^eL7CCuix=s8F2lMw-56_{eeVr@`+3K`{jXm;;67!2)P&ypm;KP^c-e1#j+gz@JzkXKRnzzQ zmyeg|s($YdwW=1+b^nNT>N|GmXwPx3?{{3C-}gB8Sv%l9)&9?(yLP~H`y4Ot-{&~> z`ISn$X#Gg>-W7DRraZoqetdncP+bJBZqQfvn~&2Et5<8>dyZ3|e#dLNPw#Q~x8HI3 zdix&l$cNhM@~PGOs9k-ZyHakb!n!~8AK0!w_b&rm6e$jipN>|*`{h+t9M)~#nd2M85W&fAQ`_!M~YvH@1`rPuk&gpQaJ{Ium9*MH{JTT- z$@&vH1D*8t+fQETc%^?<3p;o2Q#tN>mi4KOuaDE-e(fsj+2=l7Uu|#d&mBeIepb`` z&wQ7z|CP!(COR2#pSo_OURybigXp=cchYl5D*7lpUg@87ydRy@4+$^*qqnRo*6zCO zb>82;>+(@urmbc0kT(pn3_Z{7^@PMp~ zS03Z&9^>}>e3Nf>@V7J?b_n+ z{t9PSbRX`Q)g$5RPFL6eIcHXN&pe$L}wX?Jh$e}|Ha=lb*)Tzuv@Wc<4Ie0&) zJLG8++(Z8^FN5wi-Ewv1G&e6#bKOtr^bb@{^Hk-GHEY+@<|g>l;tx*N#?<&UhZ~dA z<5Rqk<0-0-w>VfIFE5#%8lPxY!i^4IIARvz#-=<9_E0tDMk5$8QDou9-3v9HKCW0H zal+v*!jX0>6Mj_N8Z8;!_GqU*Y1GlQskuF04I+*% zww-8{mFCJ?@%eN7`gr?8=|*$9iMx4J3Ae#Cb$udCpN=FJ!e<}9fV>i`#jmPI`~8wo z+16Bv?ccoljDneuj~3T2zt|?EGa=_c0RZYfr51i#0fY~g5m$zo=n$;NUmgk-f1*>V zLFcIy{Sq_>eZ?KVPZ{Yj8x%)UNJeoFFV~9Kl)@C8Nv{WYQio}M56GQdRD)<9nT?D z#fUVe2uF4E+tuZ3kaFE6GpXN(7*|RV($x zrRq<&f6(7NLkT(B#P^(EoOd9}7bpxT)l8tAQWb}YHk1Q|>=dDo6lL$;OutSv9b&$j zu5IMu=1F36wJhHJ5w55aY<8%5YFd%A2zqyNvxs%08Fgl~!~-OW6|M2fjmmK(c)I49 z*V+VZ(LUsjW&DtjU}Pbo>yZN%Zw5J}4$@B{{jKvt`tu8vIx6nHsVDQBJ14;b!C)>2 z-{A=Z4f;zFOFd|6&}~2q=6y6DtVDR3nn6R{h8IJHEaRb-arNo%C-OQYVZ<#X0z0Kg z_y~$iN@Thr!r1!s@%H`m#o0oM6x|2Ma0LQcnWKOvwJxVc43yjAq(-zcL^K6paxN0j z87(%{;|Vsw|ERC=IVUb6 z2O^_VRKz$!Sb=t=aMg@Oab(zKYOfnc4M?Z!R^{RV5TgiegwzA4pa^WY0sDs_4s-$X zwVpCDZ{fZp!cT-0NAfBadhj`+jWFTk%uU0<2oqQ z>-3s~JM|;yKaMF*-tFZ}6DtN!Fg$=(c55;6z4^p(K5Fx>+p}!wdxv6_5KhI;y_8&>Hd&zh)Y1-kIIY(x;Bd4=W80qtCbhGrTEcW<~g`~&mW(l_a9pP zgZ-n+q$#3$!mt?Cb5af-0uhfA637OiwYjkrb&SVzp5AlLRGHg5F{yEON)#jW;B!L| zsa+Thn3*AKB6iL01>h?ULwUkS2quzAjUwd38~tgy%|1HzLq!2+Y!k>--s8kn2JQ0^ z^KGcK>S$k#?pY$vqCP&e@s`xZUs*hHV*UK1Vi=wer&z5cN}kv3R8tqjJSD-hx!BP5 z5fIy`TK)6qe&f_w=c)h!y?1Flno$O>51Iu{n|ij$9?ShWNM zck;4bdq`05$E;dL+deG{zg;)7C_}*Kl!OF>Gd8r7Ptn!!tPY@8_iD}s|K7A#0P=MfYj+gyO<7Cq^c zDmUu>SDu|)TqPIh)eGC7&72o`zaihOVX+~QcbShX5N2pj1}G@aGyoWIDCTD%A`aX+4rcwKe+O|*Ka?_^fKz4&GVgBh(;}9G4OpLTWNvV1qQwI_r81f;I~FK#Lyy5tc#nGEW9AM zkc~`;0;Mp*#^|~5fCKY~g_cWRbjAIelbf2YO^s%zc>u`?bI=0nZ%Lb30A?KI$nNtX zY90ro?tl)@ip1Ec6&p1z$O0$3fD=_Rh3*jYVg-U?%vWH-k9sZSxMd@HG9jMJb*Pw0tqqX%F-6HowjwEejvK2 ziKm*4Wj@EsFbC1I#>`_OD)W)sb)+LdUwQ%=mxi-TvHdZr8<=BwgA+W;b~lt9Pu-GK zc1?V&|6lw`^dx-OG5E;S9%(-y$N3|R4=-;yTq`y#ePt1GNR#nSGp);hX;4b$$O~<7 z;pg_R4Z6ys_QORfeQuTTX^u}f?!52nqT4~2{*-{&Aec!xK8nWVwl97`8evwC+o%pO zOo0107Y|QJ*H{-lud5LefEJ7zJZrP%tjWz~C}}JgW>qaZ7G`0$zb(KSood1h0w<^1Q*@oi8?;&q9R6yGNRa`&DGN=4(PEq2?IpKbUZQvmUkqcA)t;wJZMHsM>&{`ss7JWqL}r#F zT~~VUX{BZ8?uB1FSu1uOL8E5ij0=uS67xd#V)+G|R91_~2iF`5rmp9`mR|x1Lj#k; z;Pq@W`aQL*BjBwnV-+uI$?&7a_H(ThH0}%>9PTujAPnp3 zaL#JM4!k~TfYD8%DvW)*;RApt$VEsEp%~9BRj1rm@vonoSNUSCsggplGHmYP2e;4w ze!}cy#X5jnSI8pY7rP&fKmY&{T{`XAg z!TjIL{eX)s7FQ(;1_khi(^$>XQw$_@WwNk!Q?ZEsR^fIAfRGj_Gnl$-EUGkA~VK@8Urlb)_K|tRlfmjP+fFHp= zOgxk{oM*wL2A1|ugeSoOA1iFO$ns^XLV;n#_<~hpaNQV~hJnQZG~Wrc(~bS+>8oRB zr-TuXo?3rw?O7QkV13l29kbnx(Mm#;&3Y`0uqjeHq@5yfTHJ~T5YmXa)uy5dhShtv zK*B^iTqc~f>N?PUt4_FH%1ky8q?@>sq_o# zeUQk#>RRKISdqsv?5?sa1L~rf5Tf9i>U9?7bi2_j-$@Nqq6660#rGYMwWjCK(Zt@1DK$TnBZUG^!HCrU*-1B(%Ud^F2fp*I+`?rxU}f z;)X*So*<*G=k7n0^+WaXc180z^b^_&1P}LzcZ^CCqs7BoE-97YcSxWMUb5ef z`^^sP9I2S^DZbCjKKkf^+P)}rftGj#=nkWSmkzyjh%EZiMa6Ac1x`NINd>a9s#F7M zfEmV!0e2%x@$|iUvzERfeWiDdR??S?R`6H_j9zqE&>t0^P-wYGQnH(ySElA6ELd#WkC0v!NbL4^JahnPZu`*c1{pgU8KUA4+ zvcD#?U-Ado2MjIJr?f3weXW-*5K(jqcScsXa==t2Lhi=n5kU!Drge-=f}}4YQ&tz< zWx8+`kC831O)+3zTCb{q-`MCP!2_KfJZ~CJ?RKxHRG8>J+JJAsy|bbMVz&DH`eVhO zhxcu0ADO=dW^ITj;Ag#}6RiU^PUkuJy&N3GWDwV3bz8B$nH=^#N0eXk1WF}eV2c)z#E(4F@q{;I z@n#PHT+ssZs1*d)BKJ05xU1Us$DUGv2!^;vzrk+(I~u&SrkrRKEDMqyE4HMLPpL_B z`mO11u1l9*tqBP*r{@QcT08TdGvQyO?~vUb<1<=4ei1nC{v%n`v|9h+-yoNv?aPpI z-hsB$6X3@FB?JSu4?_r~fN!_Vz`SF8fOjZlDeGN~L}hcK6cAzD749Rtcv$#F?oDXzfcKZ`Vxm!m(9t%o2avtanpk+=eyPHg}GVJb0ut`38i8Fl`Aabw{%05BWMh*$uWpLgJgKFRT^W9(c{yN-19w7tA zAl(^_Ri?OW%Kp|WV@>|Fl0KIL$KV;Ct|=Ix#&sK$lW0}_0Tl&?LdnQgGQJkhOfKQDJT^fcXb+vOV@A+6x;?D zxA>%|Ixz}bQFWo=KHI(U&Tobb%>;*a;`m|W0m3=qJ_C{gMHr|V;0uoE`ZCiF<#gvB z(pa*eS%S`s&auv7BnFU*Gb>**J}8^180y!Q{FKt$A~UJ1zeM=QOJ*Qigs&(MHR}0v zE7svVuj2BVO3LZQtPG3>LuiHowM`4|vu>ZlNj-D;D<^iecRqJUs{?;XQ1=tXwr{@m z?1RsZMf`&9tyar zX0MM=&5AqX!DY{>F9RyP*wz<}343*98~*lX3^^|Z=(PpjpCj|F!-9{$`Z&Swub$nx zc<@`g(Jzh*;@F0c8KcgP$d{-K&zDqyNAGzJE&YvXCLjnQIc~_@vN3Q>bWOFJ(R@=F zXhUk~mdJ%F_R3IQ!Z9e)U($XPpV27tYh=xTc21#B8lWs;Kva z&*3bl({UGPq0Uf_&M!iKLcPhIhKmJV33DnWC84J=q6e_^=>b`5Q8V6iOiS|t8K1Lm0#);s$>=77*j^W%O*3dDBi7)XK73qToAWQj1E&WwYyvv z7tZ^P`f}LHsp?H0%4N^%(bQ%j>gzRgCHf#1UO7C6lc4XSr?fSY}D78y2=c z{qWsRg))eslJOn@*Kdj?9sG*2dRbmq3ZCUS z$VibqoZ8q4pnvH}1Zhk+NA)*9kXY@)&VNSA%Qrv{kuYIDZzMXGcg>J&ZczY!5Q?ZS z?)|CU}1GstV78TSLsdC2%zgDRDN0=^6QN`Gr0$T zsTYo|RU%;brd67z5=7x0(TmFQJ&4WHpb-mbbJr@dnV8%&5L*zL5oN^T)KUhBSR|8r zuRL3(R(}56+OuKnv=k;B%d>QHMi!vfkZ1Xk2~Y!44XC{cKVUj~khl)wmq#PnN3_$O zMAeR^`^&sYfvK$>J&TphKupLasA&s2Teh8zBoM(`MQHRxno;y&l^e%Kn0(j)y9gr0 zxNrh-7N=5WoKfxZ++Snvqyh8{ur)>?L=v4y7dp_zHQj*(gIZlXL63e%#)0Fb*W(E2 zfX9FyQd}J5N1S|G+~|IC{*6=Or%Pnp6g0Y7+w1+buDC^BsBvl86t0u0WSh!iN6h9w z5jfs*czOT4wvi~&>~%F!j8t@C6nl9S*JuT@%rdm=UG(j5>n$o<}do}L*5+->$c z*mFY!v1lh)3`8bZt7C_xq9c0)Fp1E(C3va#H$sV&z;d=;YrK?BHbxeX(#xaErTIaZl2ZIg36IRCSddeq zqp?8<3C#!nazFHvN!1`X3^uMoqfYcoyWV(E9lGY3?S~%Fl+{(It~~p|QsxA_)CNQ8 zsAe>2i!dc~9+bs!WoIeau*$VGu8$1RSiL@*(O34CpY zT@L3Ey+vfr69T%fJWLLRkG5B<>7S=LwiLS&?Rn@9DBY4Q--fTaw>08rxH#MXfn5aZ zc4*e7OrTtn%YFD)3urWx^S;~R6|c< zQRC&br^<_!OlMGP*qq&@bx98kolLq6eQ%r9Zfbfz3!GOwe5DgMVU(5ULJaZ<>mNKu z4DgIJYc*F1Cd)H+!r{k;(QAQeg292bM3G!=-{6vYl`2eu2xJPq3$EOLjfX*}j_N}UzAIQHgbeQYnT+f6 z`@$291BB^uNIbvt9Q5Pql{^%a2Cj1a0`$-w_Ndojo4MbaIOdu=i3>!R@`n4`Pt-l^ zCmdD|M+7(K7SvRcWo8WOhF8@YmO0FaPqW@#tGrXHS2C;w6C+p|*OL*2yeY9lxGb^N z=(m~D%_~TJ`fTh|1csW{5he~R<|P4?d4a1EJCdHA;MiHp#SO1s2v34FDw{S|FR7Y! zKoZkwk`{&|q`p+@TRN1@BX7ngTAC4;HWt-X=w zOKvzb1-dRfzGD(2E9rWP>X+vg2-BoxZM&*jo9o(ZW%;WE+DEV0ORTHdKi;?h4@??|s5@@~~5gk}M8f`qjG;rzDh_gAw7Fjl>J>#E7TrBgjq@HHPV=bVSQ=U+Z zo(gJF921C9jJ2P1RcC^?U9i zU8ix+=DR*8#%>9s7k}xCqEy6cX@H5lGf>%Eb=pWMDu5MAhCT?qZa)eg3I~)j(Dlx{ z)P@fdhTG5s;A6090_kXkXf<4JCDQFu-;}=>lTg!}PSZOj4ztH}g1pI`SKcp}%rDo_ zq0n5o5G-+s&Bk2H$ERa3|3Q}35E~MhPRsmJ!^~^cSs2*z)nqeU$pbHO^MMnmpIA5~ zgL*;xPLfU_n4BTk>L@aXDa*?=jcBk{IRokE=@;}5Y@2xjat4x%KsSJ}JU$&wL&MO{9h9Wo>x-bg({tJSlEpRn zSp;NCzdV>c)}I)ai~RcXu2^JQj4_Y6!LiON@fb$*E%SrsP$b48ptM=3KeN{{J%-hH zAXPKAxO`D2+}nOgqs)-emgEt+%K%D$s)haluD#T1nqGMpdzt}3lHGsg>Y^x>rvKc` zTlpsrmfKi1>S}LvP%oRplJZCe0````^3?VV6>k^WMfOi8!PogG+fFba-uw}}9;Y31 z(Z-0WYcfPRQWQUJ)~7SHf-Xd*ZPmni0!9wgv23jc%Z{ZZ+D3d^@vGXH~|-P3_HshAI$JrIC;1oNvZ%=e&| z164Chv0%$&E^=sL4k=Iw2vD?P7cyANR1&eelYBJ0r@I0-yGb+ACleCMrcSmhLi9q` z>SVqsK;BkMMO1FKDok(%4kR}HrUM{(az=~&>lC%JG4;~3r8Gpap%Q{u(hlJxMbQ`_ zkR7o@L)q&+me{At)LdvSbvixFfLJI>3ttx;OrTfq{(N6;4Gm9;KNpY+1 zWhKyDF629t+t|pcw;uut70p8<6T;FP%Edbw%HC4fiseg-GFEN4X+F~4Er)GzkWQk^ zNvArcnUCx>-Md-#r=>q{5BmwT5Ji@CeJ^3!k%?TdfU4kpbZ*t^g$-zkr(c7uQ60{a z<2Wg`MRUA9l}YEE&;pTOUM?RhPVwx5}-6=O+HPLdx0@rc9Nc*5`7) zuJNhqJMZiMP9L<`xe_d82q_`B+~we^#gR6NPXqlM_A95Uf!Qpc-kyPah%rve#Z%Uh zTI$~?8x55v4BFt2KBa%ftFM7k6x6MOD+F^_a^6E#fzo6rcB?)npVk?B+HM(-P{L2mwuc{8d z20`tZ6?YpghOMMym@PlJCxR%dv2OnB$A-`&!8>e0Y>7V2=(sLq9C&$W=N3EJAe)@i zD>=BEGmelLDIVMwE zm`j%arQ$}xU=`4rgg_8LA6BgRMlIR0NeKHdmlKn@;Y>+{-7%{UxtS}U@{-~hE6>3_ zFuKXX8P+f+8{H3l0CbA<1ff|dQY)O-39(ge`^b}%AsE&>KS3Im`%3`Syqw4o27waj zK|2Dwwd-B6IA)L$^P!I9zO!B(+J|`$bh;e{#x+L%e2#%}AdH!e=+zk;#U7rAm%k%m zW_V_37OR}FP6!aL_xvl$Nv##%KDzHHskNPD_ZVZyA229>e)1kWlwHbvQHuQv97YF8n$WKlooko zE3-wOQ*saBTij>+(Unl() zH3fQ%Z{_YIoGZG-b8k6nmj8GQ0-yk=AQ%RPkq-{R2LoUzlbNFaRlrMhxRGCw>os^D zX6Rb!UI#0|%`^pr$&~nMiVt6}m7K4=`TC|7a|B<^$o)3clBF~E)Qjyo&Fk-4^3DRt z#Ca}`5Jt|?@H)i?GJ{tsrc;Lf2Kx)DLMcKgc1w7jqTO2=!V(5JxO5J-c$!h)&);9XhwZ< zqmGWWM-WvpL}p{qBeS{uYS@khz-^k+=EO+Mwhis)PLdL6k!Fg;0BMQ-nHjuuajqR( z5+Jub6JNIK&Q?44TcC+;y7(5|kkDMYNPnX_K0CQFLy0>|<$7e;${z4Yt~^0cNo8Xa zxH)Ksa#`OPs-R)C08%3Y$0i%+CatHPzkq*Bco*5$nqr(8ah7%LMV+70*>@vodieIRjRnvy=(w z&d7mVaCbs3m4(e?3=w!xmBU7Z*2?25ZshA|X@mwMaK!3iw1Y}Th~S6BSB>VhtiElM z;pHwDqslMzZVOeYWraI2PVGwwc$)K%%VnM5vH?HKDZ|pJLMx7&>iViQVaAE9Q06UzmgpN)1(~z!}}>g=T-fh=OM899OyrB z5u~4)LBqE-L(YdoE6G4^P7}E)nhB89D%CG?8#%~E#sdsU>V!uC;F4}xUE;@Qhaewh zD+*z9P}-k`83P~j4}Jpf2aH9p*xQc1GM)EHvMq_*;L!LCtUvA2V{EyFtK5u|`fDpJ_C zW}a)r+@-`_?idnx$&h_cy6_z1)GRt(VOuY)L*s=gNMEchJYHSLDetc#ITpRR)d~Oz z;&DY*!o3bPC`L=;C5sNfwkIM0ckX4m=ib#4@W6Lgl6U*nB&nG$}K;F+VMd`NiT3k&J}Az`e3^13ix%ZoS+n&Abz>WUJQsIIW@$^(5kK z->82@br}Mv>O}?!*AzkbuB^E*W4ar#B}09rPq=oF23$ijB$FTVrPClY8*3BANKt$| z%j3!GhE}XX?ocu-&$v1P^+Lsf%9=Rsfkd(26JB@kn)Vshe%Qh#urNz=%V87SQn2X! zLb*Xj-+3($FP}ciycVUU^iGtBQ=3yS->N=>^{Ut}hvxmx#4-QY&;f802r(02d2ce# zfb>Q_&%&M>@6(W=K#F`2j2Gy50he9&IvNUBfs?^` z7d++)J4Dh6X0&_TAi}s_CG6v}FvNMI#r4ZqXhv3j?}PJ1jxa=6zTv%zWQQdm;3CSi zbs>8wmK&0-EB=BdTp_<3`q8~O(6jLxL=uDeKE}$UNM{5QC|V1z#a1nRK0a*mOxqyX z>geZ8v@0i1;&124N6LwK*#1lhvJW8MgB9(l#a}|s7#cE(bXX)@E3fD*!#FW{!CsaWnwip{sfk7!*IeMCIB9*T zcxQ$u5R|3}hLSh%rYj<14sykGM6s0pPrv7|uQZDdM?K}FlXWsqHsy(+omw~>)_oGTlq@)3i7Q%zHk5*3>?MBl2Vps~K;Cdkk|p(G?yQ^$xnj4EaEOs*ic`$~HIy~J6OS13 z00tHJsRtYC)PX+36xR9+a^DTi02l54Osz4K))oVjqy zb=#nNG97!37+~wuWhe`aPH5#Q#K}rGFxi&Fe|q}v)8sff4)adA6yJc}4)A7k5C9x{ zTX9$qc&&(Nk)ZUE_NumexXFr*X$_JOs|q<4BM!v}S1@8M3=>>}9w66TRSI^ls#6bkWqQ>u-CqNQ7;hj1u|alw^mlJ?4^^NP zIm1;(U>r5vWAPzqg}O8NC`|Pogv02~e`EIoST)0N*r?tgkaiwvFP%!?UX)K{?b5@d z3oM0)7T2`jv#?WZ9gxe%+Lh|n*cC2AzquB*)Syp@PJbZv$;F6k%Z9X!08A40m)Z2a zMIk(tXBuB$(`Fi*Yg6?=LwDYn+3b1MA|rrK%Ea1*ZT2);7{=g=-en3%5h&?3v@(~W zyHP;poB^%6*x6yPTwt{|lNmsHB@K+IAU_2^F-piMbOYKUAu(c&4sM$Ua6bb%LHOV| z$qV#ZypEoZPvHjU`_`vLxZI_hTR{$2z}bN%o3$O{1=Fk;{LWi_dqA#xOx2|3EhNk(!X@PuJX`UF{u7dTBG zm(u=$w>uXfU99i@wUb{tffF02sH84A&iLSPCS^QRg?ZeW@x|g8Qakv;9)ikcE3qY) z;R-Kfiod|3(rM_cfdU|gJ&o?qAyu@~8vH3p4NW3ooY|DI(;Edrc}4lmQ!PwvUUXY> zX%7PT;i4!vYwGneefR9wmT^Cg6va&%OV-x8dSP7paNkiWyaS`f{*7e}?%mJeG~W8O z`qMDn`?z`P)W;{w-Id0hwL9;7Kh)}1u9yiInANEVV#<;h>_=Rc zfeCRAI`D82%kADS7u?(ohAShIotv4pScTQVf^h}p;}wA2VEXROqK^6d)~8wj{3|DB z&i~Ns^Z`h}x<0zEc<{)nCx7U5bZ@#Mli1aIR$Ux!He6<@mmm;aXRR9)=?oC?`cI{L zzi{m9$8I|l`+R!hIL@Pf3sHVl{S5gK(~iZAqi6BBA>qAULbyn%0Nv^;xbrYQvt+B7 zSa`=(Y=!sb1=v?^&i1Y+5&!H4E${;G^jl;JL}+Ienic01S3;rTl=^h>?PFyyDk7h( z^O#}Y-QvW@WIo1XMlc5hPb*<&+QdC!?&MvBp4}$UqnTV2I%Dvwgi4o{j>K?eu_B7_ z*#greyT8dt*)Re!<}t6Ta0H4Al4gMfnvT-x-XgZK?%nc*`cH`VSc#l%U>H558!!{X zLAAT3^ded;q8iol!{-PylzV0euGjn`vN8|sE89X>c=6b6PdU-wQy?(!w`ziE#P+K@ zGh1+ti8;D$8RSDG4&a{WQ~y^Q8Q6hQ47=pV(gR`(_%VU2?;Tz~WOIPFR;TgeBbn_ek*uqN ztS|ij_ET$fn8Xi`%Y5dA{fPiTgfw!PR87T2o`Co4TDT#Nmm`_TU~>hG3EVV%Orchz zx&$4AwE30!-L?{(i#vDBtE`dDe4%y(cFE@cm)WOVD;R;hQLZ%Z#wMTje1FV+Z(4LJp zUAmz^RvKZ`9pUU@s}3L@Pufjw5lHHsqWDlJ)6HJ`sA+Ouga;|l4j5o=qoV8ylr*)l ziT&i2{!vG8v8%k4LD`J_k08aMf6aQ9-WZZ-WNDvLRbQ6)2aG`-aVDl8anxzV6UDm| z739Jlva_`oV{4o`ts!G<0S`(Q70P3Ei+xAii(SitFm)MD6OYRnQS%gW8OxntXu=&! za~%iisk3uWoj$Q)0WS8o5}K%gYL#;Cw`iw2b2PFRi1En8pGn8n^qxu7T){XB1winc zB8gIw@r-OBDzhxkUPe18)>NMh)hLC9(dX>I4 zHqC;wrZX?LfM0rpn#>dv7Eo|NHge%)1Zb7!J|Xo-&NmhYMBC%kK#*%o)sdA%vB}=_ z_&W9jmxJ~Puqr1FeBPq5+jGU`mE1O$XYl3w6A>y|uD#$aF?F$)U!tzUPX5J!Z*$$B<|`;Km~=x<1? zSg~?iz^<8SH*nM)B}bzyV_J}PkE7;39^8%YrHX-?tb+cQmQh~S{xhwV=H-&AVgbK7Dj4i@^_&=V9i)p4J?!WOvY>WKS}Mt7xU zPFTOhuO01DNcc-^_XV#pLn!XW^8m(DbMwddA8#)l&1{1UyUOhBr1#K_$@h;q!3J%0 zeE(u&CX8RPx7fdC@6l6N?0tFuH?)n|`eVxnHO>$?U|D1^=`F;h;IbLzECIsGwsZ+# zG(3A9^0(f#D@V6z&UTVzY570d2y669LqWQ1swARhtP?^g#XVpgP*tV}Iqp6sQq-JrHiO^+1C>~tdMOBcmK4#VFWFx<0Lyp`{|8E_^CUimnXQQApQ zHg2=e7SuZkxAAlQj+M3PbM1_1!mCFrQ4&vS)&+iIaE6^vS)lMzdk1;QO(4{fl^tYs zi&Y(A;)|L2Nn!;}Ofx@8vhy*->Ok+5#{hyV1)i;Hj;y#Nnx^S20nyG(Q$fiT zW{S{IEOj427v!(lb5O2Tmc9_;*dyz}rZY#wF%asLmiSBtJpF|2(rhe{dCP&{m#|0r zl8;yGm}P-sk~x|IxDe(2j9x#v-7L%7`X zET;~Ln->J`m?0QIuI!X9At~>8N#YT!^T6EPI?|hh=6)9VxSEDaP+(Jepkf+t9bo;b+ zndY6&&s6mASzVx$TT~FQ)Qo-&Tph@TfChlw*H}0J>^pmryW=iy@ugbPyZjQ!+R_Qy zwc8@g=FSCIf|r&mhM4$CF@(Z5?lu6^yNeWr3S(qI>`)dwuu-cW7MOLV!?;=hrC3;Z7$n0WdhN&(Id`4X+(>1$X2Wp3~UXI%-v9lm?P^7=b4NhtRrv5b`m%?gZ}5HcDFfjuzXK9RAYc`gv;MJv?N0#iky` z{_mbhxN*b6hWRV@-f<#JAWsSZutC55T%j*sH)R#UFTY8}l?ub0Z|LqUrKewKq%FrY z7eJMu!b?EoUsXw2+qtNUbuBx7PsRtoX9;dCh{F|@A&2}ycu|mfxaifV)1>-%^Xn8T z$IruSl@eumZGBp}JGLIlT3QXpB2_JIw-mLS)}uytUvzVOJsyE?kc}NzpDPna-7pt9 zuBeI9XWs>Cxqa)%H$4&l7Njf+ErKs@3B4pr)7F&dzorD6wqo9unSWJ`V>b~(aXN$$ zQbzx>nHUp3!rwZ7y4$M1A7tUzg)J#q1sd^`Cxi|3;%pzJM45&W(E~gunF_k_-A&UuHSf`Ie9?c%LMMxc4&yB3JiD?t2RL773?Ktx1FJb=?2G{JWR~c4%ni^} z{J<3?D}vtf4GCc4cw?C9wWux#90Fio!%VE_L>K3LR8@Y@>ugA?kp9Rq9wvR#NsJJq z){BB+&;Jea#p<$VeTd$3^bPP_S=FV=m(_Tu1f|hizEXVdF3zx)$&tS+jhmG@uy*93 zu`q&Ydt|?cL6aLfnfhag0ePe}+aql|l@tIGH~F>5>}o7Le1-)kt}pK%%S&YHWoG$% z$J$zdb=~}ybGS*&=u`-)ng$8?wX@9N9jZLQWsO!3CwtXy$=tQ|B$tI!RaJ1cVLc)-}1fwGx zMMn)m9;hJ&Zm~$myE((oWX?t?7aCSYz$Q2{-86J7LI6l*cszs+u+Z!RsAL|#F-s>A zzdzYXgQ6@eq&z2D3hBgkUr-yMrh9chf@XJR?V2eacLs*IG!T`*9S3}1jnoI}T84tr zA|Njqd0e^u``WLy-yx$?p}<4yk7b~KPFocPVml5@^ zV7^|73&u)`jhT|WIPHr*N>GHG@jNFrj1(i!vJdKpCH1Rjmz8+T&@Xqn(Wp;Q@LR9<0hwYFFJt&XY1Kc<)l#&z3H(I!wfFU8_>g zzznl0McvTX3~GQ6>c(gpG$0crwJePwcWC73G#5!$g2oOS7z@JvxQQR#k7g zgLl~Ej2ymhew^(U<6MP-KpMv?O2+_VdQeP>`XP!ci{4xueL2whyc&VBff<6bgF#wa z+o?MQ#2{efFx}m?bUI`a-5jFP5ejae76XB>sB4*|*=S^rK8s5pZ==yd5wawViW28e zQAH0kQ+2zQ%Lk4D1JBVh{^2&gbMuvFXgMqze8!Nv-y>*J4iL2_Ud(Ay!|kUgz~Iiq zJylv?zHsX@7oItl8DvgX*?6#Hj+1I~i*EW!l^gZ>%CmEet0Zup>)7AAiLezN;p|1W zs8Id#9r<{0O(7J&r@uv8B4g{*U5ioLQmQ&rr&SK42Mzan?6-Gy6Np-!PZ#^XexsfS zd3Zu3R`>HF+ie>1=@N%+F6ZJ@Y^!`&4Q%Xfnar7Xxa4-;YMMTV>_EtXCy?h|5}MOt zlZk+^+VHggdJH1P{vSby8fc zdJAH@J0~Ul_a)WWaog9JtWbbj^WJN5fdGyE8`9 zr3p$x5bSgkP3Q+D)471|^yj30r5;54Rz07x=InpQqT&9$(_i0xV$ZUA3!f%0uP9hk zbl2b8O2y;Vm1TfUWLfE>Nj}?w>ts3|i@(#CX4caN3kWoX-Se3MrBb;&+V&GdwB>0* z)(v5C*R(*yzLQ_qG&2^l$HnPf)A(fN2MQwq@7!D_tiRYM=jjKFVsxvPPP|FwBV)3- znES;F*IwV6^{Xp_w6}8bJZ{5Drp>!Unc9%ADq@fofmJR>RSJ#wU$f-K>^>R%iF+gL zZ&Bec&}*yn87>0Pz$Ph`>KRz1PkP=kk5IFoxm2(0s^n`spDy-&?M5-pL|M&91J5Ky zl^j<3$;yy3WU6#ZT{Uh9&Vo7(8sln5IX$WBLcg-xg-|aU zv8#$=YP>%2p}aN;ZQR_c1;Wb>N=Fj<%}L3;=(?w0Rgd=jB^w`H>q?+w^MIG*8pAfn z2vq7ssz9Rz3PGbSY2FUFg>P8~Qi8ZYp5t0nRuCH;&$>CVh{Dc)1k92y>*DFp%K*XZn*<{ww=( zXyE-v6glUI{c@Fs*8c%3$o_QkSF}~kCj^Z#Vs4S&b6oN=Efv)&N-#*EvjHl+Ngqb* zL;=2FRPzOF+) znk|RdD>I&HI-|;x8dH4=?wsXH4+^z*lBG3NF@t4OamQ78h-!rb!ir}{gYIDEqJa*F z?U6Ky0lJy~3>N@SMS~ijcWSY@+SUXJsh(~=94_A46(B49Lx@47P!buQJw^4VlY`V& z*U%o$yCc)1IH2%u5?ucQl>haOI_tfUWiG6KAx-RQy6_L*f;{sp4`?FkN;B1jc%U>K z*U$Gkbfw36n+U8v-fZ>2;)73pG02o(3Lb=w0)taqZRt-=uxnkGQtD(E27=~<8b}TzJa&*k#V2=R z-!5+R@a>{w@E+30M&4hS>WIXAh2g?~AY+dJo~T5&MhSg_oqAzEzDCjmdAAYSz)KUC zS7BAo*U)|@>T*IsH9`muQHOxTas%biXJ8-55O{*no7QJ*DgTmarpCN?Ch%9E@l4>1 zm6DtTvF0z|F+bwZ@g&Q@NRzuZwK>|JzcZ1u%n^wBp4e9@%_J@zJl}IjYcS6zJI(}d zl*=84H5L{?V7t={AOn-ZPN=*xL1}XG>eP}d+u26)AcqzTx2;8gup9#g8thcc`S(@) zl_$Rrp9+-K=fbXDL;=p6Kv_7JJ<|kjnfXwwO+)Nr~s zWi;=!@>wKRqJ~9!EzBXF5_>AB(~yf`5(i1T&Q3cBL=<;@PQb8t^?A4@z~I{YA9sk+1eyqT8pJMD!oAB4XIMSvz!-BwdGP^mNSM3Jr*Y8Ho;P=0+FF| zzc+t-Ndw58&^yV!_PzPd@@^&rAceSrb1fF3u(POlo`h>C8?t}RelAr#=XM5%4j{bz zsz?_>$?_JXdV!ZVX_Z%Rf5f-2`G)-p4Xyk9i`QR&{ogK%;@?rZU*!Rne^=$-Q+ZJ3 z->jD*u7XZj~>q{D&(4K;6E0zCRrLD3@Wv|Nbs_avFT;&Os z{VE4kzM=AuRi0EisB%cuv9RF0}Fs2o%IJ(c4sCsaRe4V3oXUBX=T&}RGc9N?c>Bu>wFRpAzEbws_j*rRY2ah;`qY4g zFd^t4AVSI@q9ce%S5|?#uzn6(BtdnnQA>A+wM8bglg-qKXbj4jD&bunJmI8wSYx_L z3?$D;Ln_b1>5~$(sBwG~oH+HP2sHMa1=p5J*&W`kDD8eqRdo#-kI z`kB;_TvwSctFQn7+ej+;RY^wRhvCKL@zGSfHTx^c#VeyZ5kk3G=36?@iCM&jO-V zcnJw1K)AsazFYXmyG;P%t~@KMQi@^wvSP$NEb&#Yy|#DHu~UcU7PC#-^ARAQ2Tg%P z_4#rWjaN%aMAS1$r0P|48-V9k{RMZjUe@#7&!%_-2zUZ~kfFp2Q@q)jA%)2i|CZYb zIh48CI#WNr9R+s+ueniWd!Z_Tu{Z$;WXPc^bBt*4)vlB{RL4mvO?-7&tqg6iLt4WC zL_whgQZ#V1!Y-w&7x=vW+06MCKTn}JFp(Vxp}gQsx&}TCjtz$q`VtO>RM4dzxRL^h z$x72 zu$CG5hT9Rr5Q7C9!a|q{s28&(EH$d$`?Ebie`v$fPR+vhGtK>M*~U6Q8E2w}LA3-1 zxd__kcIdg#v&b<#S@_SnhV9QrVq~hUjYNj6U}3Y9nc09n>KWz<0H9(jR&Rzrv`v#Z z5~>>{#PAa!7uBUGukqrM#bVE6PiRurQapa z2Zcql6H4BO!7P5!xLuV{z(ljs-o%8EbkXIqjE!Z`U ziA?k5OWlV^eqQZFWX5~UrKg=_K1209D2Y(Uo!ny|>9sic1(8j&wN&1R1n1E3?CElC znd|k~Ntx7O6SoS?|~X4bTRL&^*E4^X>#cwj#?aMWI?8Xt_~I?$u~iyK-;e?%Nx&WWL*o;>JV36{ z56VWSqR`xVQEdC@!)L%V`4r9HlvVhnMQJn3X&|FHR;3_YiDV@i)T2M6jHRDd`Pf)T zH$bK6hMTir&NXz!T$4W&{9*g>Z7k=YMEDubHJXj_X+G4)XUe}VF2)xr3#pMUmB*-Vgfm5>1s4ghzs^!V(1}ArDB9L)DNVe;YkD6|38F zSuW1IYEg`+rgyZN>`!U4iU;PC4K--8jvL~WUV82YfVJt7|+} z?$%D-#lI_FZq>@;=?GUOB_C1$Ow?YyCWn@xSHy0Me56=e0*^AgX=QxS4~zanry-ax zu_f1NvE}^d&v`2cZ^(6x3CWpi)AW}Y|Cr_DXJePCSREvjzB$+6B-#9Cb|K;zQi9c@ z6El+?3dx-yjyXd{Go^QHYE!CAUQc_JyhJDbnV<`p3pf+UxFdoCEoc>lT)^5ZW5z+r zIhoqwZlI7sx9amfQQsgvU=u!|@b4#e2db^Z67&NK=$dSa6mmqQ?xttN8#a6G9MQ4Q zjOUz|Wy^uP!DAruELUfxTgKse9*K7J%4AvRSGgAjNPxa%$5=^PYR&S`;^5 zKUK$qB1x$9%ZhzX8c9q1jE*+OCz_MjG{##hQ{|_bG848nCQ+#^D$Yo>^_qOAPrg=~d~lj}%hNu`4=)2%v{3GAXGay* z_+_oy_eG7wNA&kTA%QVHwL74>Nfj=5D&~O;3x4Gp$O@5=L=PS`j4>16z}X1)pc^T| z9nE5kX#hIVzihFU249=yQU77ioYq_!Z|Q6Iv)YP>n+Mw``@BdtK9?yw6Eo_Y&y8n3 z*{ZmsGQj67+HmP~ZkdSXkK}d?_OFtOsW*b%atz zc>N+~>2w47nrO;@1FB!S*XHV?_{2@yY9AWEvE!Cuxv;LnFK_QGkL&-Uy5agJkx&@(uh430>xhrXdw!lOL8f4i%waP;7Z6V>V+gMVG)OTA)VKU}ue8aq%S31H zxoLJhH}E2+{5}ctEIa0f%HAdmM|T}rTKxKHG!6^ySnI%Y_D&)nL&%%s$?n@A_ci>) z5%iNQYPSaV^#wBrCdz%uvn-|q`*s~Uu1!e18+XWOPyix>2pUZ1wJNM+;L^Nie1pt4 zsBegFz{({vnak`L+>lAz z*f|atLkG}!zeERw8(6hmGje!JstnEVKJncrM>RNOWGQ%KZPpar`m`8inx(cMP==3R z_dJ_X?4^qZgaE@t-DV>v0&R!MD+1P1pz~|@cWhs|cVzoYuvvDkL|!r*+^w-aiN){6 zBuv(wKl-i5o=T!0lLQGtY|5mHWS|!zoA4ZYCt92kT>`1@g!$le+ZAjf9#D3fm-lib z#?r$ty?dgUxi{5x#Iww0n4K2=i(ing3pZSbgYrsXk)g<~RFL_`6j(#RdpHh! z$x)86f&{`h38p**z_pp^tQx)H6;83B%rDV

eraA+YY9?2cg#onhL1GfuE)7v_$ zDmcO$958%}fCtyn*Gpmrq&7<@1smX+v3x^avaWE^Y;0;w>whK$awyC=LlvPN4CXk3 zi!-1;jb}J~EVH>|mn1Y)h{8xmkeZ&;;=-UCZPKHuE6k4ngmTi2)jycnF0W89RB(IP zBfZCII!eLnA)S3&(Hl9-s#{G|3qWnh>;Lt7S-W zPEHHYBUM+%+q=+Rd>|p6nT@%0tJ=)yC^`YXnd-}D4-`y?*2j=hTENPzEzuGYT|Z%@ zC~BGau2+CrtU4o&2Q&V7fdXhFBd`!&zORT~*CsBA%%n=+0$1XB1N@x~4S0~82rXji#G!l( z3zBb8tswa%Og1TV`_z+cYY|3kGj-U7<88Nes@X3VXEj2mUZPD*QyPRw)0*)01}oSd zZ*M>J@;s?ABO!6CLybtac25}1-g4iWU!ii!JwrDv>^pRA;obGT5j2*h9y24jQ!8$J zE|a-5(BT0jUfLHLk{T(BamIE!Lp~R?qybomk;PY67M@AXmfQteDu`+*_RFOB{ zx|bOj0rMR-#kft~O70}x+7iul`|$fw4j^m`c{+qghAIeD#i$YzkB1`@p}MrjMME5D z>?y$@u{NM()!5@8>5`%aQD%%+m6Jv~5NKzjGj9C){Q6_Xo?S<-Ug+H~5M8rL!^ff-ELNq4P0osCgZ2a6 z_1iopkBY#s4p*!yd8w%zHOJ*_26^C2iTz1gwh#FnlW_Q;yT6 z+pJj)bEZLI@hne+ZscfwWkYNv074K;lHQ}s>yI5#@S2(aV}zt)*21RZ=dT(q!`?8+v?Ggh|#-)xgY3tMLwU)Bv=lA9hEVCE%8&K}}NE|@3*`vDI_9J5y z+ixI`F`!t%oyfX90kKqt(}&XH?4rY|R%XKWwvfOn#;H9f%;D=YUzKF-BydJ31GcR* zRK3To@Y6sd>j8>Tl-T2?Lmx^{xO6~7_21Zwv=B0zy3HK!W2C7x3F|AMo`cRv3S-N% zkIS}+Q}-Qp{bFL%%Eftf<%VP5M+MT1Do;&FA%e^o@aA@@^AJ$hpln*JZnLtE40~^X zc|=RBv9DdkcqAfE{efV9?_MxBoYbb0n#{C(kgLIA_8T!GmAxyg#Ao9;Li3@EVT1&^ zLvjGroujC6nQ4eyYm47Fv*mEn&P=u8f-2EAHzW2GMiQg|JqLYzBw_)Do_S#9nv`|L zG|JT|r}@w-Bd!yfosrk18d48S6e#QDrqI^ZiY{S(NTJ)-K1xb*>&XTsj+$XMcAVSWC)^^P@>59zB9dpY!Fq`x+PqJ zt1kqsIboph1{nb?o*iV$R`tQFx-SwJii;=(V@)8}UZbm^L%;I=TX`F3HGC|QYlr0I z)?z{D!6lztBzwQ~#OYJaiJ#Td23`${pRniJHTod{BAn7;DoDiI>-~HwnTZj4HCJZ{ zdJDqMrEgvoY461=?e}^3ocJ|cj$}EIn-!2v8}mp(T})Lp`*`JUWj`wb&ZyCfWFw;h5O`Jo{ zu!qj1>L{n3?Jb<`$4@=5czpkD2RCRZ%x0phs`xQJXCxsDImk=-tPlYxe zgcbK0??gTtJ{Q(7gCt8#(rk6Gs1&ELsJ^7^hfOA#ENv?-Fy@_{rjRtrl&T}suDB5$2FC6&xF%tV_ zEYv5mcohxHF+RLvRX_wRi=w}I@lWunrUxUJ#S&eMi6us^S3~VSj^G~acxA@Lg*NGg z#{;cf^pJt7fs7aJ0|gR{NZz!3DJrgj9R*V(3ug4}+Q&QFaQQy5HWizVv=0G*Q2%Up zn^xW@wRuc3sr&Gk=XWfz+sUYwNMO6bwVG$g!e!U1!&y)Q!;q0q#0tqko9EQnN#Hf|K;8BMRSTV4lZSWR(nwLX zG=kK1yv%b;%hlqqaUc9@v7`7!^+K|>P&ZaRw=*AQ=i~P;GFLLQ`*YSK9o^^wyz;6_ zuZNfpLNCKroe7jd6IBkNIn;kb!_ghPbZIp&X62j?XT{J~_|fjb^r_qgsk%{(#6C`! zGA4Ysv4Dh;wBCO1Ys>4FwmyyRmHDEo(%SAnAYin5u#=b;0A5_P52U7D*xzSL0YR{( zN4U2jLChV8JS4eaPzEEf$J&`emxC67Q3N;2(4()<$B|J4gqLe4UitKzgL8{>&oaGL z_kU=?*DoI&QQGCk)Pm|W!kVU`03v)@)D>wDiH2v={sQzucfB}gVtz*oa{IHWDl&&= z7WOFxI-og;F~P32)Gd+e6GIlu({MGCa3r zVQB;> z(;l)$)Dpk)YC%cR6C=tGW0avsMm|QFp+Qt44l;LJeyhBWAv-K0^E27FQkz_7;=y8k z;g5Pg1NcJPig>2Aly#Op@)rg9LnY9auB=TeU?6m#b=##&bOR>F2qS2`D1ceUVKaPm z`g{q#fMoUY*%~;WNeD`^ot1LORX8roI6=-qJ9EFGidvpH?9l{3Tk6+}2qtIhB8OJ! zcBC zQ8>#sDRK+!=2GBu?+W0 z0omQ>Gu3!uJ%*4q3c}_NJ!b6y7KhxM7jL*5z&Z@#s2$49HaAGUE4Lz@A0dc2=+_Zx z_T&3wO5~(6^5P5uyWM5Fj+Yw*rR_e?ylFYq-g%V<8Ls#2f_TSH$K~(kI6S{VUZGY) zW(=&@&p9&09qhz4Ldx|#J^1_WWAtz8;moSNSf)g%hpqFk*!%7{t*UG7{ZeP>RS^LP zq?chBU>Fc&P_UpVDl<|npr{nPs3-{7P!ScepxD5IqJSW%fPjEt$u)_wMUBbj=Bi2V zO`@i#Nlg5nXRW=@K5OQ@n)tPU?#%D(Iq!bYob{}-`&xU`mRZFO<|u;pTI}pxH#Ydf zEYF&qd-rl@dUhna*@~2&BBx6|x>rVOn(PEfh)P6LSmQVUH}P40MypI3L0Gs&6`?~7 zd#;H>O7)YhJBcoI(;EGX2u&khX>g#)4jE0hpG*0_EyIDa%$V zI$q|mCDdGM`t0NA8wr|SUshRVu+F4FY|zn170X7VaLncjoC!2TAlv!9FHl)b4*j_)pvtT*Wx5`>_US;T-P zaR_5&|Jpp-ci)#=&k{b2NXN1{y$Jc1GtO!t^Sn+^n_IRdldBIojaA z-YcrEjQ<;-wfW_wrFH@^>{%)MjiaJX-wgxB5rc)4(}g)#9dAF&pJ0d`=Abh)=Ocq#pL62dZnk;nU!BFT18cC?Dg? z;)FW84djEZI=d1{CD9!_J+*$fHJr*9Z08IMCBPxP&c@SOLzxXjuOj!=3$Mco1Ps6J zUMq5QpC7-KoohIl;MpFoqjR*(adR}5xc^n?ts;81Z&6u^_FD%{yV%EWNU(D1K?yyU zzU?55EoKld99hbzU7YHyTZ1E14hgh_{v}{(yA|hfs0L0m3_?3r-O;dTzX5&wmDb@r zx2{ykfSv7Lkvr0*kKtlxfxRkvMfP!fHWsYou`3YX-t{|spclgLLI;EeM)#)gkshYR z)oPlQ?(je_Opa>e`2n;Bwven;7-@)(1s^A(>W7}E+M`-sI8-L9b)ZRp(j$#{SBCka zw_mQ+g_Vuo(VX>;JxaY}o1}MW3-{qA{9TRJHU8FIb4+!C`9OM{#x{UM8`|$+Zehv? zbmQilD@POnYtM9qXlm9Z-55uQUF|J@+j$hBqsUc>0WQ@8OLxPommByJ#=R$~(6E7N zBj>s^=$*0d{fm0QUTEu_Crd&7Za4TadqjoDmX@Vay>&U41*kRGT*}12bokC*s%)P? zhkI^fL}&7w-l2{jc&*LuHoITn>_uMWhC<_^@|uO|mT;&H_dt5~C#=ADn$Q4uXj($m z)u|IWoY5MHwk)YPLN6fp&ElO}aQs~JPM?U=XrXVA>Sp}wEr(7&u`k0J{d!PZ*^qwa zJ+P#CaA^sL&*z!g+no2I1e`!n3c$x+OiJVONAxT^vyjN(<0NHM1+lGS0{R zhQ7q7kw9_xsD61j^hMu6&w5}#abQXVcG^5EXq96wou{MKq`P~p9zWWmTA>NfnZ-<0Lr(YMR)II7?wj( z4XVh3;}rv^7Vm2L;~sRgijFQA2(YqbkfipR&%{`eOQ(#%OAQ3l^#E7bbl_Byp3t$6 zgkq}muwlHe^&F>$gGJLU*vy(^dTpC!yY!(+*A|Qw7fpsEL+#L~h9=dECNXKPh^otq zM_tEFj8iDFHT&%vsbhQXG*UtcB{Svx7$QcNyz>4=o)W{+5nTi@1Pjx%-GM6}&!P)1 z>{}{hv_);Ib?$>AC`El+`SPFPLaUPGY?JcGfr&5BCB=gfv;u|KS=0Pg)lW)1>BfR> zFsD)UP$DS)jkhR0Z6mqAESjX(nd*UusZdWHF32#JWtROKd2VOQdMqUxfyPbXZ?ud6uv;4b{YvwpxQ+ryd+u{ zyH6UEREYSKwLGVlUeu?&^XS?=8+*VbC24bP^`kyKR5SHdmVS^5OLs}s61W7xHa4%8 z=u#lZxSY2ZO)GX2x^Y|iyUpHOuqL#udFB=a5*nRaHZ3*`2)Q7no$rBcCjJ?5eao^9 z=XcB;J7*`@2d03@AIBqFphEAT6=8ZI9{u2|0Y#%Jx#juq-FRb3W`Q@E&tl3iS|klHpW|`88w|3d zn{OXI!`*9;jGoH}OuHBj=4X2g*6B&gwOnq+PyW0{Y*iaLZ4`O5ga$OS3GRa{Zt(Qo zXH%kRajc>9;=g^Xvtr~-0Dk(FFc~QKn)n<5i=HW|QJS5m)pm!C3A+oJjgHc^)jSup zS!+;QtO|KDu!~f!`(!_KCh(njP~u?klK9%tQK~s?aTre1h_k*4dHO_pk~Vaq^44`^ zjTWB{L?N?rb&5S99r>|29vHFSzrM~Iw~@^&bvX!4EW5jGkl0WJ_a2Si4~ydIp@7yj z9QsFpITB-^m~WKkkfoMAp>S43s9vGIxv#X@rW@{NYy|b~F|?@O+W(MeoBxx>f{J%U zzdorc(RfT}YI-Q$(Ft}E7*-Ln=(4&9OO2|q62yw+W^G-))&I%+N|hozbm_{A)hR85 zzw9=Xl$?vfL4T~{IP_;PBW3ISk91#!?Kn-ClQiGLvIKOgS|kASb%r5u(n5#)dgO9g zpwXF2vdkIF5FV2nOaAMF?I2tGH<%7rQp+%fmJ46o#+_{(>uqksGS*jY#Fq9}$aidY z`hUao)&EzkN{XI3p|7(xd8z4|w4G=}O1Gsj(AS==mcO9A*z+J$EQOqjNjOZmP3(_x z0hc4V(8H!^xcJGbUD^(N-Ru~$1g4*3UzM#tTPwmL2VCk>*P3tLEQ5;)e2hgO(NYu?Vr z`Js$ec636Ge#^}FP7$e1RXE4FWLI}g#)`KqKGD$Gx6qkT6<_>BGmD?NY#2XjXF~r^ z>j(V~mBJ}+-}^ecJ*6c>oFfove`{`-Ijm?<@u0z&DC7~rXoZnt#!1}i(aG)yamJ#s zcvl;27C~d+oO+Qfcy;_cbwHxHWDp3l!cUF5u%@!5g|?l;AN1?_$3`=Mu;7^i1+8Khr zaLPGnVKTS*YbEPw*~z>qbut%~sUE?kOQ&fsPg({W^|`P3bt2t5BJ`v5A8L+dl~+bL z%cK<-)O0@+j9t1{+o&Vi^Ms)wTyg`>cCSL;aNm}emLtsQZSd_aEf~`^gzSzlN87r(}yl7U^KEgiUR+K=?8ARS&;pW+-KI*;~}4wk0yBvP2g zrUX@S@PRZ=%v?Osxp}oOeOGj1=Bg6eElrP?)9niTbL!g^CRw;LqEg_i`H8O{OwXso z@I|m~4u*JPFg|<3F_Gu=%hQe4zut=4|NiIN(J;OVGe>!*m)Lf&AFlJsVWw1@uXN+5 z5}*~RMA)#iLK~d!9oZ=#oyVajsXxev2E*U#_E>%H-~>Dz>Za=7Q3Fvg9Dxx(+jn-T zQ!5fD_*c}NK-Cq$>W&ZUP8KJ+&?MW5C3e=h-p(hT;a?XFsjE>fsYZ9cn~ zs$J%@Hef_YUo71{*8f|X>`L7F``yzrd^d;9VLg)UYbR2tUs%H>uS**wEteft4P9i# zHO|AW`3;G%8=~wGno2r2R*i@xW`WOGP_;&6uR;3?(LsW*q-lla0=93VerCZXvG4jH z?c}qY%MBJ_RBVyCI76et$Mx9yWX~J-yfyok&2OgEh{G-M5B!aLp0S>1kC`I^g+aw? z;8qYV#Y&HK4j@^D_Q$$wB3jxN8%s)E#icgEt~^KMS{?D_EdAQiXY*}P3!EQW$ zFfSw_wele({Y!Dj?KaX@fDItHfgn{B!ZL}4T@TiD?1FOLW9}=PUT=*W;9q>a9!6I1 zcInpzl}a$NbdigNCRHZ8%gGSBryOyem-y$j|6vifs#x>NYtK^_CGOFmMBklkpP%#! zEx`0E>E35h4|Gjf?36?O%eQeTRE9qEz~YipR7aeH;CYTbxQs4E!|(`47jjBd$-06c zu6(8aE=Iv>cz1}4O?A0x{haV*Wy+pBtCZN+VhgKXrB2zx(e(gz{R{IuYUaTJDbm;@ z^UF%=m$GKd+_&fegdRHLO?WpRHNFOHq)Pt(&Mb-cc9e-S9)Gc_Hd_+@a+Z#Tkj6U* z)#%849I8=J6Jv2{VfyDLO@cY87PJ4?4g>igC1KMQeh94>Ynw3httDH54D{{OWBpSu zs38zI)|Au$JJob{R3)?j_a(0MRJul!hRU!%Len@Z?uTV=yW7{etu0?FS=WQ32pT_Y zqLCZAyInhba6yQ$=RQx-2F=NgWt~@&DxTd4wnD7&(dQspK0fq?&)+!rjlAoGT9u{P z{O)#^Z49Nx_BHJkPqU!W(zeSu-$(P0kLwP%+7~k7v{5t`+UCOeGF6I0L!yT$QAp{rb`Tp2OEfnY8%9*-T^wey;E;npUMy9_2oaW* zf6L4(wZN`FN%?3d17|6s2H2u1%XI$K+AlSON#{IH?ZvXC^LFZr5XzSAgRm8)U>G`1!IZ%$ zU1;*+U(9Xg`zhsKwJbJNH;YY5pmy1Y{X=$Y*cmCOmyTOoxQ>swjXA6|OZU164AwMG zc2hW@8NgPitSrUzKj&muv!L;f1kA3X7%X=KFDnBp+UL`GnAEyTy4_{V#G1vPx0ya| zGyR0Nd;L+RY4gL?81}YQle93xU?({#oNadRdW`|gT7t5UFlDj%YTK!JiB3vr!x2q| z<5&LoSKC(l?U%J;;nFZU?h}crFXyx946tJwA_MX*%W`&_&#{7LtIUp-&RcWf%9jst zDQD?fz^4z9Y3|af8b(>cgZd2x#5ro&!LhtQW;)yN`tur2b-dz7a_HM(BnJ(hUF>Ud z?>-7QM-1g&$79@sYuZb{eQWcZ_^@ERLjdV--YFZF&;EoHR!SzTNX0ger9F0Zb;GeRn59x#$6hH}cjwEuBVOmdcV6F|+BCv;F*sqxIv@;A*}K!)S8a`byFpp2RAHa1*D>3| z-;p~~Ek(Lo)`2l6FDS=;Q4Q&bboGPPP3Y;2!#j1{sy&4G4a*QG;X*}4>XyhMssdVS zzml2KNTz~xM~MLsrV7&Wy8gtgL8Ym7?0>J?ATU-L7)9`wzgGD42upbzr0W72FRN~L zC&GwYTWWR(RWxkQ;_@ZSU#T~~q>PeDWqsg4-lup!g3>`xm9VtD7QFgvj76IwfFk$m znpe=Rrr8y{u%mp~??s&eQ&Rfw)}F164n|_9noX9DFm(M1P}=Ppx#Q8@UvJy|W_O$% z{22Cr;5|lmU@cX-S|MsdXPM!9`f-kaRc+R^8k^i3j+jRx8|$C6>wRmkdqr12n{Lx( z(tl=)rO@;Mit3TwIX!9oU)u+bGn4UTavixA$Csvy1dT)IrnDtsIi)&+HE4t`B^#!i zbh=$_gKY_gfK@%{@dZqA1mKO{VjPjf#xNsM!1#;~ZCR^nge{L^=QwVlukpu8f$3j9 zAZho|hmm$qCUh~(LN_4Qza*tSYm}b8QTui5nz8G!o7;HMqXZ^fJ~&lvqzeE`G*{ut z@_=u58l{<%Tdrn!gXVxB`Wd2jdVS~1sj-Z;AbNY~oFBVR>_UY)U%OE(XPkayLsG_e zu6z(iZ`@gK2{yFzLScj<1js-^g|WT{RrE^%oaRfM_*#rU?kbYCNq zFr&kRNwBiw0cEMi^t%VUaYRQ;BNS>V-CMO&gDhh@0EP_8N?}-QaQu2=SSR3LDKJc9 z*7(i;O?+0LVTXc7PE`btER0%pveb^M<94obGuPCV+y9xl(cn&^0-~w-zAo)MmRCPM zOKl4gJtK4{h(Tq8OZ)dj9ej+oKd@BG&iHr2IWw@Kc>F03KiiZLjlXUCp(!0%@1-0_LKTuOr`EqS%u3L30(C9NG#uHjTz7lcIs&x; z2X-i0C^^0$v3=vmRE1xCz^VgJ-5$LkDl1e&uv}C^653J(k4|lPH?60$Gpl(ld#u~H zX5X4wTT0e(0AhBp4Q%l*d)2HF>HGOpcMPtY-kP_z zNUAJtQBkz4DY0CJP5>B7EjQXqglbK<2{8;nHyx4f4g-T>!bvN7_NcVt<05dn*t6pi zw5J^rt{G@II4SNH2mghJ7sf5@6>EJNUc?DBPDZ8X;{q;@Syki|XH9|Yw?x2YXBBR| zOAgOrkAjwirartm7Em3MvkSm-p-rYX8hmfMlyk%&r-}|3geB0dH*s{qt{0_(aq$Z5 z35+6Z9jj6mRhIjPvulBE{fuHgR0YP8hTm z3n^ufjB3sTQ?$CUM2)k5lDssUGl3HuXMe+d8ZpwsgNd-GPs91Y5W%S87?dxasmQ_>VzD4f($jIKpBSh8;C8(bCf)gZsOju|ZS?9J%BK8pf69_x4V z!6J7*I{*CT*{l4$a1EyGNpTPa5Ik>ujjdx>CLv})>2d{7T{V~2T zbT=5(tX|BCnWXuqkd6B{%>!TGhae363es)g<^FopxJfjusC-~-P9 zQOc=7nAMVwOKFiDZGMSM=dC!L4OW|8(nM|W=QY>AIwY2W7*4Kh=459U*i*)IB2M$d zcs5-}h0eZC)9L?ECG0E)`uvWK=5^Z#M6y!=R3ErV?pO_+ow|$vPOH*tg8#wi!hh-_ zh&C2;kjulexGC3b&tIj%S)>eiNE|#e{>S=QII)oDfRuDe^|QV}4jrV0aj+De9dvq!V>jYJ#oC`| zjtm%VS3GdiyPWYZr@P};fAr{gJ@vgG&z)}c)~1&~+{8T|Ohh8(Zbzh?iPu)N#NkIM ze&c>nP~gsPcm@W>XSc=oSjHQ4pmRUR-)Gx7H>tom zociF}G{Eykc)p=?3p+a38zboFJ2`i16X%+C#_XP3+I*{3H?26IO zeGNY59|Lg${}E%Hdky&CI}Y-J%-W6xX5fDYe+L5przb#W2w)p?l5=l>kEh2uhu64) zRn9F3{(Db$?jOMa;RNSK0Dr|K=N<$8%T9spfd7C~otp>zALH+6;BRuebC&>r-O0|) zfc$$;aqf2T@#-1Q^@05VaVCyR2Os;UIyVY@EI!-0&%sCAX}}CU%$bhw0HAyZaDtDI zW;%B?_}F|d^aDQjJ0H(L58vSLRPgiS1=+9R|j^O8>xsW^f=`+u{3&6+6^PTGk{C{8I+;HH( z{u;;`dRTHTbO=7ay3V;Fz<Y9k1B$d6Y-s{s;adM$?_X#kncKJ$) z_AS&AkozCshD`u}<-1yb`@RR82JYM5$NvC#!=FKyzY|@;>MfkR@&%aPQbZ=PH5sr)_c$&AI#I0Xeq_xI4DX zIZW!gU*YdO;9k}t=dix%h85-9+rWKv$DG>-yKdhp=Qcs!Qx49#uORQQI_KPNz<+qx zocjRs9??DL-UI%&J#uaX@Hgw3bB_c6*j_pJE8ri~JLf)v+;8ZUa~&Y}yNYwJH~6UR zn{&U#-&G|!Hw=7S)-UH8L+-Wu=iI&EXE`c&*7t7?gj~SKse^Lv4ak3QdCrxCk2*tg z?sD+)%pp0~1$+z~mUB;lk4p~Cxld3&ZycU;e*-^XjmWu~z`ySBoa+erzdRCh1pf6$ z5~fv!iqFIN<*2XviD5=Ny}JzX$Gd$K~8B!2R0sIX4t?Kj?& zxzf(3=Uf%!K450fT?gDB;%{%@e&Sr{3xAiK4?P0^-4{YGz`yFEocjX!zrHxknHww)>{x-EqIuEc^SrD`0y&XWj&EP+rBhGdUK^B&pYJbC@UuRO6zU0u0Vw$7 z4Ra8na4f0@{X_|2P11j&gxF`-?=7YQxj)3AwtmzEIgyC@+{3WCCN|CR!g z&_plw0+G-oo?e0Q@Hc3f6bgigU-YY0ixdikhXu)7D5c-S68Rk_Qd2w>ij;y53WY+D zQlU_20(zuSC=@RHqH!~QOIk{WBBhpIckmGag>FE`Cs`6MPxL?=p6!+=-%&~}yd5+g~F|4mkP3mLSclLZbPB) zD_0| z4VSyGy^b0Sg}?Zc(O4*ydmb7Kg;u{9E)*K8lsxLjLSeb503Q=j7~^feiQ%D%P*~|H zG!Y8u%8RC(2!%s`yi%mpL?~S4DKrrZZ~9M&(Ex=|o3PD}Qfev`UhxuaDiogcJTw&w z`>t*#JTw&wUwE5nDil8T6xh~JcaM7sHZv5O35C_3LNlT8AD)M1LgA}51B5~|q0l0E zlD71Qrw0#$6c6mJMIM?9g$ultnhS;XkSEJSbEWWKZG=K|q3|=0y1DRhxc@(_Q-VUM zHQ2+76j}&{#a>4(gu+pH3n{b^3QPMwD|%=l6b5-~Z6Opkc)4ITH^oCsL!qTm;FyIL z*-|Kc?)A`8D17lwKmQ#|+>4%vmO^2Ww_MScGN~F+~w<}HbUXA_x`;Dd1zxOv=JWu<~hO!dnpPB7zzgn4?pvII6x@8<#{+j zD0Havp-AZfq44j?JIO;GFD1A~q$spC6xs@f54`=g6$+<=cS@&@g94HjFdmauH3Nv9Lq;Q~6_~iD7I+MbIMoI??gS6gbCBob83e z?VgABLgC7yqr2;O={CB(PXHc#g%Y@W_j*u0_){dUaA>y(jqa7JF| zjJz%xd0jK|x@F{b&&cbMk=HXLuUAH1?~J@Y8F|GSd3`hTN;2~LW#pA+8F`0f2BM&oVziSuN~qq5XboF|); zyrn)qUEzBaoV~`Uek)MuBb7XICX$iDo%%pQz6Tom2MY5&(0Hf9 zYJd)P=w@-!Zjw8}Gr&o^5{ES(VTY4;ItU>HY!G!4jT3f}0Z!PJy5+4et4DmCu*+ni zu4kYQWSi#!WdNM!dq5ee14%~)I43v>a1=6oP$lkquSd=aMg}-17-j*}3~)}c)D=DU z#|kpQIl)YP^*jT0^D$7*DFfg%-y4*Hx`qKxJ5F+!d6U&?$BxM-GO!q*r~&Fg8Q_#- z)BwN1AhY*Hq5)1hW-`#&Gf*!d1C2cc;56SGl!1DN0nTbpa+iC^^+f|*W23RvHyWs) zFu)nj$UuEzVE6TBgGr7v@-WcVGf+Pt16@4>_46^%)hPq)Dn}FIr0FDA+gAmgG>s<2 zNz-UToHSJpaKbb)zzI`S>>vDF1sN#F$3QpF0Q-iSD(dDLD9Fb^H_rgp?oteJPIi*} zw3foKc?S8haDsjB<&)(zhR+QH~67Mp+r)Y;t6Pv&rz;>$(33GQip7 zO!5Of0}b;rFu-e|VLk>1coS-97~q8UB=>=b%n9ofx6JE<6V{OdPFO1goUD!vaI(78 zRlN153NpaS>P!a8Jp+yMF;MOqXq1luoZKS~StG*$=ffwt*L~~G`S23#fuSXW^Wl*J z&W9@loC}W(G!_PmUiwrR;9Phn14BFmjq@=u#4~{UHs2eRfyRabPPb2TPkNbiXkFrJ zdwp=aJu<-Qc4dH5?U4aawU@fP7oCE??J+pd!@y9_K$Cn74D}2&$;ZG@uYo3p0nX-6 za(#Un;7AxJborK;a~hEW&gLrvoXL+2a3&w8ntpOrjtp=nKT`vTdIqqVpYH)J#7*-t zaHwaXsbPSN1=u3O%bbe^C2q54hl>S~0WKCO16(MG3~-?UU5R%OmO|Vt9|IMh0eW?0 za#-Ot&@3MV6`p}+h5@ccOcH-7w!K`7C~>!VcDNQ18Q@xkGQf3+$N<+NO5K8GA6HNV zT!+ZScbI3Oc|HaX^9(f4$G~Bpf#!w*E_FZmG;qf| zqJabQF)+n5a6mo=rg#Pp$j87G&%gnO0WR~!AtjgjN?aJwaG5VMz-2yVfJ=Ol0WR^C zx_X~aYD^7qi7!(FGdu%r^D!{PGtf3412a4WZ4ColMT|pAt|FGWTWe`Mz*WS^09O%} z0j?lM2DpM)>Yls*tcGNOD~Op4%=8Q#n2&*(o`D1NF)-6JaG+s;iLmSvD)A#TbF(}H z9QWsWKzpO@!9bn|bUeUuD;5xa?}tmQlib6;*m9W-4ViD3xx^YZz$I4I0GC&z20Dla zK6|R3v;$mT%`~Aoo`DYeG%&|&phG?l%<&rNU^KuL+)3^f&kk2`8AYaLfGfC>0j}UG z16;k03~&Xv)a@M7OBmn^ZYBfsJp%{jV_?2#;GldA%=Zi&WEkKg@g(;&bj|KG7l}*U zO}}x0MM4Mfn)G#xsC$&1?>@@eCB@W8fOkK#^g9YtWP2 zS`WFSFtF3J)6p={F=3#iVW6Wh@YiykR_U0JfrXxdj`d<-o040O)Nz*5gZ=X?w-^$c`240I6& z!hoiWFc3y$T?_+V5(c^$2D%6XVMNv?9|Oxg16}elu*@^iB_9LJJOf<}16_rI(4Foo z41^I`SHnQpgn_Pxfv&>9xS#8YtZP07mU{*mC6a04%RK{_htKzbHe_851Kosy8NMa% zCJclTSvSK#w}gRihJkLvK+)zlVnW^WF>s$}fT1m!4BY1#z&w1u2b6(sh5>Aff?)ey4TyF&p;2uK#zn09$OI`G9E{P@xX%TDp(8k$j87_o&g3CWis%TXMkHv=XpRi z(8Dm$Qy940cO`lX17S$n(=gC8VW6jBprP#^fnCi76vMP$D((_02j1-gMkaMnaAA~Y~*?i6JNsE^gA-aFoPXfA?OVzxS2>P zpy;#dC6zvgVjrQ{(3@}{L$Qxithh`l_AwOuh}h_|Pl|o2QS4)Q#9n_XB^3+B6Mg@? z*ibC?6ekG9VneZ5DAI+W6pO1-EH)Ie?`eu+U!nMx=drJ$*jFibS04KsihYG5UHVC} zZ#9a24aE{eksHI7xa+(gvEJZ2<IBz4MprIlj4&beV4d5y)FkBiUXA5`b&i307G$rP-K_@ zDGsPcae$$S{kBsS2MWa_JjH>A;y};iIH5SuP#h=}88$$Q1FKOSXee@v%vf=9gGUS^ zeF-T`C{mYYLh<0A{bk;aZJUO(J}EO)%Y-VO{YkZ~8r3pGm3!Tvo>bR^Ky{$I&)e)E zP}Ru;>T(dMUhHy3*9f13K(Q>b*+HOKB+mb&I0zJrR6K-cE%>UT==7#KB<*gIGZcpjMLLht@`oi9$=|SqzhQ>IVM2}W{-idn8nt1D z8je{?@pq_Dd&leVP($s|gxaBo+Mz;?uJfdJXf?*+erli&_BjXv&%~ea=U`8t6gnm5K$Y%^|1TZu*)!sdUsG|cZ|Y8z`*S5-DCr7G zw@Ui|*X{mQky2APYkEGn`_EG0`*hbF-YUAu)9wB@DYtlf8UCPN{GM+2C#L?NzHUPs z#_#DYc0i-}nXXk|H;A9 z?Q`oV_djm?DH;5a+kP_)=d3$4?dpHr_M2glTT?FoMM&8VfymK@1&dbOwM?2NoCGV-p>$eWXqH#Z~is*JpO8F}+F@)l&| zU7eA4O-A0e8F|-bp3KO5DkE=AM&8AWk@tK?-sX(FEg5-RGxA=@$lI2Yw>=|oM@HU@8F@Q1@^)q9 z?as)1DI;%BM&90Pd7RY+X@ve!?i7{-niwPU;YQjdlny}HX zZZzIXO0_Tp(ba^Fc9XDC&aBnK>_d0|RDtUz0i>E=nB+HLeqG>|=Nl&ZjgZfhs`-tR z{3cjlWq9chZ2HLX(w-Ox>2F=3#DGz*swp}KjP`PO*9o5r%y)(6J6(+nPc}5)aWyW! zv$6k<*lI(5eS|V&MJtnFSH_A~CBd$Y6|IsPbN-Kw6+J(d&-rW4f-+8Yf`q;5^94zM zA@WZwo>oWm8zlJ+k^k2R-}m{P;nw#wM*i7*ZubbAxQWTUJwrn)lh{^T>T#Nw?`NVs~B{$X9{D8ME=9MO3d(o*QG(X_2i}|DpuIkBC z1aDo)HsWO@XeDO!RYSC{sw9+^dWd*!BxuCT*)he%D9{NK)Jpmc`3(8QyYILES)us> zFM?N*?>_pG&kuO(3ErJY{j`E*p`O93fuFky6t4z;egn(plQHmt0gXm+ z?vMI`Xkrv6(P$KBK% zQbLORa|Ibv+`oN0^O53y@{PPls%RPtnpZ)C6gBcOpwY<3_$jOwv$SMIKJGyK!pKJ+ z8=bzCQX`=h-o_Bc$|Ta8A&OP5BT4~LFhsE`iS)K1im`k*7E06;!xK?YBIcXO7>lUo zn?Z*Y+>?J^DD@E|3`0K4_b=UTwemrno=*Io=~xG))D~246-s7BKLxv4E|-jcP0) zc9s@28VeZEXguINU$-$H5JzwQy#5G;_96wEpZu0+kfLVk^fB(4B7f%>%txwdni<(> zU|&F^fqj4QXf&|z*S^GPU|&F^!F?A9nw|*fBbuV^{{c!&A%jl;j(euKpR$4XNE3JUxAVs&RS&kH+Z*JQ}BWl*glSdI68d>IFQE)dT0Je+0Tr%iZK35F1nA zSuHgkQpMB4;AtUvru()>!|nne4ZGXsOBOdURF4nZj)vU@JQ{iz@G$fabQ^w1IUp@} zJ3pcwF-88pQqv$+JS`2LmV&3q)6^)sfJdX~?(w#)QFLcPd&HwrbODb>(wzy}ZN~;* zjHCnI1=ENJX?@WP1LmlgLjMd)czi~&wKCXR8C%i-xqwXriu*%EcSv!6%Dv1-s+Qea&`j|ZHQFtp(P+0{_#Ti( zy9G2F?H15z#9NRQBi=yi9K0x}I{Q=965;58y!a4&l|Ow;#;&}S1oL0sB;D31LFN2PC{G#V{P zNTbo-_qL?bXtjO4sL^NvM;rI|Z@&4a7PzU6s}8QZxVXtY&Teo8xC(JKz{OEiBV3Jf zHNl0m8eB76&2hEB#f@EC;cATw=QB9=s@mW>09RXF2jXgnt3589$KVdaRfG#?F}O~+ za0Y|xjH?T-uDH74>W-@iF4ic$aP`K8vld)2E}XI8N^teVRf-E|E6AZa1M#;E*C1Tw zxCY}If(vIUxS_akmV(1M3T`;A3S2lt!5xMRXDGOlxJKbR0@snaj>2^`uF<%T!F4Py zy2Kub3uh*{vA9mabs{dDmEgwVs>FqJ65PqS#^d5XITLYB!o>(pZtQs)uG4W%#x(`k z8Mw~GH5J!cxX#9P4z6jqrsJA{YbLI9ah-?jd|Vgcx)9ezxGu(Z39ebVF2#j&4&3Fq zX5+d77h9n@xaQ)z3fDYb^Ko5`i{3$@yfPvY<@N5bV24a${rldd^&>^S`-{(T&lLIp zc%J!4)f(G~HO};wuc3v(8a1?#vSA-aLko|EZL;>z(86Gi8eXWSq6NN_^7_>cq=Zz< zE29WQdF3-wUXy3EW;?*tYzGLofJUPR<0tkYuJgDxYLM1IY#KEfuxaFA=s_@Y5N-DQ z&w-68T7lpFH}082I*ZsBXNvo)xAGpTN~*1(Y46)44FL>jGz9Q;U+cvXK%&tQz<@@> z07IL^FhEe6zL}Je;=V*iK}d1mek1dds&o!C(%}Y;{oUWab~K_d;L(V_PQIqqh(6AF z6OTsp1w0zn*9>yxXpK>QsKds50J==8?)P2Ed!#Czc7kSvmw<-n z1vDC-7tm;UUK5W-!}9_f4bf9HJ+X4m4L=vme6ONN(t~jF%8zi*6!&#^^B$>6hr5y- zjUphKzu?c1Qs zv>KlNV!ucg&p`&yL4v2y(=2l7-nquYvEC+DjfLZkKdnb&;R2o_sfB{R7!QZ?^a^w#QRs@DvFi)~Teap>07v8rs$%`HnrlpL>02Xj{Oe;cY=a3~z(J z4ySyW;=aY3yhjQ=H_CVtsmiCL!P8OjT;k=Uk!k^tMyf#|f>k5cu15)?d^A!m;L&I` zoQ{Xm)f3+l&vP$UIfVWqEq9-NH~?b@rsZzM>Fi-KRXm*xo=$>?^R|?a2A%~x8hG}) zAKS#hGvd*}vw%l~&sw2Wu(UJytlVvF@{78ql9szS|8_v_Qc26*VQ*hht5ni*_vE4D zfSIY5u7d^5L%uH5D6)V?qsUhKlBZE*uR(W2qful5jYg7%ULGUKU_DnhgG88WtHFS= za?sZPFoVC+�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%0AEmj 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls b/resources/knowledge/original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls new file mode 100644 index 0000000000000000000000000000000000000000..77e14337d473706019772d10521e7371f06549eb GIT binary patch literal 667136 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

ka7AlM+V!C()84FMYpHVo`xu;E~jfISNK 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 A&;S4c literal 0 HcmV?d00001 From 7cb82c6112e95053cef3dc4bc45438166211d51e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:04:51 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20323-9=20=E2=91=A0=20B06=20[=EC=A0=80=EC=9E=A5]=20=EB=92=A4?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=3D=20=EC=84=9C=EB=B2=84=20=EC=83=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=E2=80=94=20=EC=A0=80=EC=9E=A5=20=EB=8F=84?= =?UTF-8?q?=EC=A4=91=20=EB=B0=94=EB=80=9C=20=EC=8B=A0=ED=98=B8=EB=A1=9C=20?= =?UTF-8?q?=EB=82=A1=EC=9D=80=20=EC=83=81=EC=84=B8=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EC=B4=88=EC=95=88=EC=9D=84=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EC=9D=8C(savingDesign)=20=C2=B7?= =?UTF-8?q?=20=EB=92=A4=EC=B2=98=EB=A6=AC=EA=B0=80=20=EB=B9=84=EC=9A=B4=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EC=83=88=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EC=95=84=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EA=B7=B8=EB=A6=BC(=EB=B3=B4=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C)=20=C2=B7=20B05=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=C2=B7=20[=ED=99=95=EC=A0=95]=20=EA=B8=B8?= =?UTF-8?q?=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design_save: 저장 도중 표시(savingDesign) · 다 나간 뒤 · 실패 때 풂 - design_view_cross: refreshDetailForStructures 가 저장 도중이면 차례만 올리고 안 그림(최신만) · persistContext.redraw - B06 Persist done: invalidateSectionDetail 뒤 redraw - 시험: test_323_9_save_redraw(저장 전 옛 자리 · 저장 뒤 새 자리 · 도중 건너뜀) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/design/design_save.ts | 10 ++ A00_Common/design/design_view_cross.ts | 3 + B06_Section/B06_Section_UI_Page_Persist.ts | 6 +- resources/tester/helper_323_9_save_redraw.cjs | 129 ++++++++++++++++++ resources/tester/test_323_9_save_redraw.py | 60 ++++++++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 resources/tester/helper_323_9_save_redraw.cjs create mode 100644 resources/tester/test_323_9_save_redraw.py 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_view_cross.ts b/A00_Common/design/design_view_cross.ts index dba4f7730..8b7cee4fd 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -38,6 +38,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { paintMapPick } from "./design_pick_profile"; 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"; @@ -218,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; @@ -535,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, diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 152a6fb81..3e870bbbc 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -381,6 +381,8 @@ export interface SectionPersistContext { * 0.69 → 0.9 로 고친 뒤 저장했더니 정본 한 줄에 **새 측구 기하(0.9) + 옛 절토 면적 * (0.69 기준 3.33㎡)** 이 섞여 남았다. 만진 측점만 새 값으로 서던 자리다. */ reconcileDesigns: () => Promise; + /** 저장 뒤 새 상세로 카드 · 그림 한 번 다시(보일 때만 · 323-9). */ + redraw: () => Promise; } /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ @@ -499,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/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/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 From 13608c6be5c294ff771f26a178b6d1dc729a6a88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:07:25 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20323-9=20=E2=91=A1=20=EC=A0=95=EC=9D=98=20=ED=8C=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=90=98=ED=92=80=EC=9D=B4=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=EC=9D=B4=20=EC=98=9B=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=ED=8C=90=EC=9C=BC=EB=A1=9C=20=EC=A0=81=ED=98=94=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=84=20=EC=95=88=20?= =?UTF-8?q?=EA=B3=A0=EC=B3=90=EB=8F=84=20[=EC=A0=80=EC=9E=A5]=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=EC=9D=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=8D=A8=20=EC=A7=80=EA=B8=88=20=ED=8C=90?= =?UTF-8?q?=20=EB=8F=84=EC=9E=A5=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=97=B4?= =?UTF-8?q?=EB=A9=B4=20=EC=95=8C=EB=A6=BC=20=EC=97=86=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?B05=20=C2=B7=20B06=20=EA=B0=99=EC=9D=80=20=EA=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_Api_Structures: definitionStale(열 때 판 알림이 표시 · 저장 성공이 풂) · flushPendingStructures 가 초안 없어도 옛 판이면 정본 그대로 내보냄 - B05 보기 saveStructuresIfDirty 같은 조건 - 시험: test_323_9_definition_stamp(옛 판 열기 알림 · 고침 없음 저장 = 판 도장 · 다시 열기 알림 없음 · 또 저장 안 나감) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_Api_Structures.ts | 18 ++- B05_Profile/B05_Profile_UI_Page_Structures.ts | 3 +- .../tester/helper_323_9_definition_stamp.cjs | 108 ++++++++++++++++++ .../tester/test_323_9_definition_stamp.py | 53 +++++++++ 4 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_323_9_definition_stamp.cjs create mode 100644 resources/tester/test_323_9_definition_stamp.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index b8665eb5f..0a89149af 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -214,14 +214,23 @@ export async function dropStaleStructureTypes(): Promise { } 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) + 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); @@ -296,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; } @@ -371,12 +381,12 @@ export function writePendingStructures(projectId: string, next: StructureInstanc /** * 미저장분을 정본에 쓴다. 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_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 669192248..0009778ff 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -19,6 +19,7 @@ import { dropStaleStructureTypes, fetchStructureTypes, staleDefinitionNotice, + definitionStale, saveStructures, readPendingStructures, writePendingStructures, @@ -204,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` 는 // 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐). 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/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] From e067a2243929c19c2ca29a303cdaada96c40d610 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:09:49 +0900 Subject: [PATCH 24/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20323-9=20=E2=91=A2=20M02=20=EB=B6=80=ED=92=88=20=EB=81=94=20?= =?UTF-8?q?=EB=A7=89=ED=9E=98=20=EC=95=88=EB=82=B4=20=E2=80=94=20=ED=98=95?= =?UTF-8?q?=EC=83=81=20=EB=B6=80=ED=92=88=EC=9D=84=20=EA=BA=BC=EC=84=9C=20?= =?UTF-8?q?=ED=98=95=EC=83=81=EC=9D=B4=20=E3=80=8C=EC=97=86=EC=9D=8C?= =?UTF-8?q?=E3=80=8D=EC=9D=B4=20=EB=90=9C=20=EA=B2=83=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=E3=80=8C=EB=B6=80=ED=92=88=20=E3=80=8C=EB=A7=B9=EC=95=94?= =?UTF-8?q?=EA=B1=B0=E3=80=8D=20=EB=81=94=20=E2=80=94=20=EC=9D=B4=20?= =?UTF-8?q?=EB=B6=80=ED=92=88=EC=9D=84=20=EC=93=B0=EB=8A=94=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98(A=20=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=EB=A7=B9=EC=95=94=EA=B1=B0)=EC=9D=98=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=E3=80=8C=EC=95=88=20=EB=A7=8C=EB=93=A6?= =?UTF-8?q?=E3=80=8D=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BF=94=EC=95=BC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=E3=80=8D=20=ED=95=9C=20=EC=A4=84(=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EC=9D=B4=EB=A6=84=20+=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EC=A0=84=EB=B6=80)=20=C2=B7=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C=EC=9D=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=C2=B7=20=EC=84=9C=EB=B2=84=20=EC=A7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_SO_Def_Check.ts ↔ M02_SO_Check.py: 부품 끄기 전 표로 끈 탓 가름 · 부품마다 한 줄 - 시험: test_322_2_check_mirror(벽 끔 = 다섯 종류 한 줄 · 맹암거 끔 = A 횡단배수 · 맹암거 · 짝 같음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/StructOptions/M02_SO_Check.py | 15 ++++++++++++++- .../StructOptions/M02_SO_Def_Check.ts | 16 ++++++++++++++-- resources/tester/test_322_2_check_mirror.py | 14 ++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py index fd56bf337..04c8e392e 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Check.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -104,13 +104,20 @@ 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": - out.append(f"「{row['name']}」 형상이 「없음」이면 횡단도도 「안 만듦」") + 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] @@ -142,6 +149,12 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] 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 diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts index fa88aae0d..2433880f6 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -121,11 +121,17 @@ const hiddenIn = (doc: CheckDef | null, tid: string): string[] => 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") - out.push(`「${row.name}」 형상이 「없음」이면 횡단도도 「안 만듦」`); + 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)); @@ -158,6 +164,12 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { ); } } + offUsers.forEach((names, id) => + out.push( + `부품 「${shortName(partOf("형상", id)?.이름 ?? id)}」 끔 — 이 부품을 쓰는 종류(${names.join(", ")})의 ` + + "횡단도를 「안 만듦」으로 바꿔야 저장", + ), + ); return out; } diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py index e4380b2dc..4cc5e3673 100644 --- a/resources/tester/test_322_2_check_mirror.py +++ b/resources/tester/test_322_2_check_mirror.py @@ -84,6 +84,9 @@ def _cases() -> dict[str, tuple[dict, dict | None]]: 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 @@ -140,8 +143,15 @@ def test_check_rules(ts: dict) -> None: "「반사경」 군 글자 — 만든 뒤 고정", "종류 「기타」 — 지울 수 없음", ] - # 형상 부품을 끄면 벽 다섯은 형상 none — 횡단도도 none 으로 바꿔야 저장됨 - assert len(got["parts_off"]) == 5 and all("횡단도도 「안 만듦」" in t for t in got["parts_off"]) + # 형상 부품을 끄면 쓰는 종류 형상 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: From dd355890793c66db0a8b0dae586ffa2641dff046 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:13:36 +0900 Subject: [PATCH 25/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20323-9=20=E2=91=A3=20=EC=9E=91=EC=9D=80=20=EA=B2=83=20?= =?UTF-8?q?=E2=80=94=20=EA=B2=80=EC=82=AC=20=EA=B8=80=20=EB=9D=84=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=E3=80=8C=E2=80=A6=20=C2=B7=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=EC=97=90=20=EC=95=88=20=EB=A7=9E=EC=9D=8C?= =?UTF-8?q?=E3=80=8D(=ED=99=94=EB=A9=B4=20=C2=B7=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=A7=9D)=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EB=81=9D(EP)?= =?UTF-8?q?=EC=B2=98=EB=9F=BC=20=EA=B0=99=EC=9D=80=20m=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EB=91=98=EC=9D=B4=EB=A9=B4=203D=20=EA=B4=80=20?= =?UTF-8?q?=C2=B7=20=EC=84=B8=EC=9B=94=EA=B5=90=20=C2=B7=20BOX=20=EB=AA=B8?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20=ED=95=9C=20=EB=B2=88=EB=A7=8C(=EC=95=9E?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=C2=B7=20ownsBodies)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_SO_Def_Check.ts ↔ M02_SO_Check.py 글 · test_322_2_check_mirror 기대 글 - B05 3D: buildCorridorStructures 관 몸체 = 같은 m 앞 카드만 · 벽은 그대로(wallSweepAt) - 시험: test_323_9_twin_cards_3d(세월교 · BOX 카드 둘 = 하나와 같음 · 고치기 전 두 배) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_Corridor_Structures.ts | 21 ++--- ...B05_Profile_UI_Corridor_Structures_Pick.ts | 4 + .../StructOptions/M02_SO_Check.py | 2 +- .../StructOptions/M02_SO_Def_Check.ts | 2 +- .../tester/helper_323_9_twin_cards_3d.cjs | 87 +++++++++++++++++++ resources/tester/test_322_2_check_mirror.py | 8 +- resources/tester/test_323_9_twin_cards_3d.py | 42 +++++++++ 7 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 resources/tester/helper_323_9_twin_cards_3d.cjs create mode 100644 resources/tester/test_323_9_twin_cards_3d.py diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 00ffd48cc..74c9119fe 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -54,7 +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 } from "./B05_Profile_UI_Corridor_Structures_Pick"; +import { builds, culvertBody, ownsBodies } from "./B05_Profile_UI_Corridor_Structures_Pick"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -279,12 +279,14 @@ export function buildCorridorStructures( ): CorridorStructure[] { const solids: CorridorStructure[] = []; for (const section of crossSections) { - // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) - const layout = culvertBody(section) ? culvertLayoutOf(section) : null; + // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) · 같은 m 카드 둘이면 한 번(323-9) + const own = ownsBodies(section, crossSections); + const layout = own && culvertBody(section) ? culvertLayoutOf(section) : null; // 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1) - const fordLayout = builds("ford_bridge", "ford") - ? withoutGuardedFill(fordLayoutOf(section), layout) - : null; + const fordLayout = + own && builds("ford_bridge", "ford") + ? withoutGuardedFill(fordLayoutOf(section), layout) + : null; // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). @@ -297,10 +299,9 @@ export function buildCorridorStructures( 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 box = builds("box_culvert", "box") ? section.box : undefined; + const paveChute = + own && builds("ford_pavement", "chute") ? fordPavementChute(section, crossSections) : null; + const box = own && builds("box_culvert", "box") ? section.box : undefined; if (!layout && !fordLayout && !box && !revetLayout && !paveChute) continue; const chainage = section.chainage_m; const stationFrame: StructureFrame = { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts index 2135085c0..3ac464282 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts @@ -12,6 +12,10 @@ import { catalogRow } from "../common_util/common_util_structure_catalog"; export const builds = (typeId: string | null | undefined, body: string): boolean => (catalogRow(typeId ?? "")?.model3d ?? body) === body; +/** 관 몸체를 세울 측점인가 — 같은 m 카드 둘(노선 끝 EP)이면 앞 카드만(관 하나에 한 번 · 323-9). */ +export const ownsBodies = (section: CrossSection, sections: readonly CrossSection[]): boolean => + sections.find((other) => other.chainage_m === section.chainage_m) === section; + /** `culvert` 자리 주인 = 박스 · 세월교 흙막이(body) · 독립 기슭막이(관 숨김) · 배관. */ export function culvertBody(section: CrossSection): boolean { const spec = section.culvert; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py index 04c8e392e..90cde5083 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Check.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -142,7 +142,7 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] 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} 에 안 맞음") + out.append(f"「{row['name']}」 {branch} 「{name}」 — {place} · {owner}에 안 맞음") for key in part["읽는 칸"]: if key in keys and key in hidden: out.append( diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts index 2433880f6..afbc38e80 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -156,7 +156,7 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { 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} 에 안 맞음`); + out.push(`「${row.name}」 ${branch} 「${name}」 — ${place} · ${owner}에 안 맞음`); for (const key of part["읽는 칸"]) if (keys.has(key) && hidden.includes(key)) out.push( 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..713b9d76f --- /dev/null +++ b/resources/tester/helper_323_9_twin_cards_3d.cjs @@ -0,0 +1,87 @@ +/* PLAN 323-9 ④-② — 노선 끝(EP)처럼 같은 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.ownsBodies; +const out = {}; +for (const c of [173.087, 352.14]) { + const one = at(c); + const twin = kinds([one, clone(one)]); + pick.ownsBodies = () => true; // 고치기 전(측점마다 몸체) — 기준이 변경을 재는지 + const before = kinds([one, clone(one)]); + pick.ownsBodies = own; + out[c] = { one: kinds([one]), twin, before }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py index 4cc5e3673..819de5f30 100644 --- a/resources/tester/test_322_2_check_mirror.py +++ b/resources/tester/test_322_2_check_mirror.py @@ -127,16 +127,16 @@ def test_check_rules(ts: dict) -> None: "부품 「끔」 — 없는 부품", ] assert got["rule218"] == ["「개거(겉도랑)」 형상이 「없음」이면 횡단도도 「안 만듦」"] - assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물 에 안 맞음"] + assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물에 안 맞음"] assert got["hide_read"] == [ "「배관」 형상 「배관 세트」 이 쓰는 칸 「흙막이 기초」 — 숨길 수 없음" ] assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음 assert got["fixed"] == [ # 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음 - "「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점 에 안 맞음", - "「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점 에 안 맞음", - "「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물 에 안 맞음", + "「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음", + "「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음", + "「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물에 안 맞음", "「배관」 「관경」 선택지 값 — 고칠 수 없음(이름만 고침)", "「BOX암거」 자리꼴 — 만든 뒤 고정", "「기슭막이」 정본 — 만든 뒤 고정", 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..7c72ea64b --- /dev/null +++ b/resources/tester/test_323_9_twin_cards_3d.py @@ -0,0 +1,42 @@ +"""PLAN 323-9 ④-② — 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체가 둘 서던 흠. + +3D 몸체는 구조물(관) 하나에 한 번 — 같은 m 카드 둘이면 앞 카드만 세움(`ownsBodies`). +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"]) # 고치기 전 = 두 번(시험이 변경을 잼) + if at == "173.087": # 세월교 관 두 련 — 관 몸체도 한 번 + assert sum(k.startswith("pipe:") for k in got["twin"]) == 2 From 0d1c4f2615e83f3fd792231c9476f920a3af0e6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:25:41 +0900 Subject: [PATCH 26/27] =?UTF-8?q?fix(=EA=B5=AC=EC=A1=B0=EB=AC=BC=ED=91=9C)?= =?UTF-8?q?:=20323-9=20=E2=91=A3=20=EB=8D=A7=EA=B3=A0=EC=B9=A8=20=E2=80=94?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EB=81=9D=20=EA=B4=80=203D=20=EB=91=98(?= =?UTF-8?q?=EA=B4=80=20=EC=B9=B4=EB=93=9C=201078=20=C2=B7=20EP=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=201078.010813)=20=C2=B7=203D=20=EB=AA=B8=EC=B2=B4=20?= =?UTF-8?q?=3D=20=EA=B0=99=EC=9D=80=20=EC=8B=9C=EC=84=A4(=EC=96=B9?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EC=A0=81=EC=9D=80=20=EC=8B=9C=EC=84=A4?= =?UTF-8?q?=20=EB=88=84=EA=B0=80=EA=B1=B0=EB=A6=AC)=EC=9D=84=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EC=B9=B4=EB=93=9C=20=EC=A4=91=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EA=B0=80=EC=9E=A5=20?= =?UTF-8?q?=EA=B0=80=EA=B9=8C=EC=9A=B4=20=ED=95=98=EB=82=98=EB=A7=8C(ownsB?= =?UTF-8?q?ody=20=C2=B7=20=EA=B4=80=20=C2=B7=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=C2=B7=20BOX=20=C2=B7=20=EB=AC=BC=EB=84=98=EC=9D=B4=EB=8A=94?= =?UTF-8?q?=20fordPavementChute=20=EA=B0=99=EC=9D=80=20=EA=B7=9C=EC=B9=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05 3D: ownsBodies(같은 m 만) → ownsBody(시설 누가거리로 묶음 · 차례 무관) - 시험: test_323_9_twin_cards_3d 에 EP 꼴(0.010813 m 차 · 차례 바꿈) 더함 · 고치기 전 두 배 확인 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_Corridor_Structures.ts | 17 +++++++++-------- ...B05_Profile_UI_Corridor_Structures_Pick.ts | 19 ++++++++++++++++--- .../tester/helper_323_9_twin_cards_3d.cjs | 15 ++++++++++----- resources/tester/test_323_9_twin_cards_3d.py | 7 +++++-- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 74c9119fe..9365f237b 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -54,7 +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, ownsBodies } from "./B05_Profile_UI_Corridor_Structures_Pick"; +import { builds, culvertBody, ownsBody } from "./B05_Profile_UI_Corridor_Structures_Pick"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -279,12 +279,12 @@ export function buildCorridorStructures( ): CorridorStructure[] { const solids: CorridorStructure[] = []; for (const section of crossSections) { - // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) · 같은 m 카드 둘이면 한 번(323-9) - const own = ownsBodies(section, crossSections); - const layout = own && culvertBody(section) ? culvertLayoutOf(section) : null; + // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 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 = - own && builds("ford_bridge", "ford") + own("ford") && builds("ford_bridge", "ford") ? withoutGuardedFill(fordLayoutOf(section), layout) : null; // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 @@ -299,9 +299,10 @@ export function buildCorridorStructures( builds(section.revetment?.type_id, "wall") ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) : null; - const paveChute = - own && builds("ford_pavement", "chute") ? fordPavementChute(section, crossSections) : null; - const box = own && builds("box_culvert", "box") ? section.box : undefined; + const paveChute = builds("ford_pavement", "chute") + ? fordPavementChute(section, crossSections) + : null; + 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 = { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts index 3ac464282..52b6b96cd 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts @@ -12,9 +12,22 @@ import { catalogRow } from "../common_util/common_util_structure_catalog"; export const builds = (typeId: string | null | undefined, body: string): boolean => (catalogRow(typeId ?? "")?.model3d ?? body) === body; -/** 관 몸체를 세울 측점인가 — 같은 m 카드 둘(노선 끝 EP)이면 앞 카드만(관 하나에 한 번 · 323-9). */ -export const ownsBodies = (section: CrossSection, sections: readonly CrossSection[]): boolean => - sections.find((other) => other.chainage_m === section.chainage_m) === section; +/** + * 이 카드가 시설(관 · 세월교 · 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 { diff --git a/resources/tester/helper_323_9_twin_cards_3d.cjs b/resources/tester/helper_323_9_twin_cards_3d.cjs index 713b9d76f..437064ae8 100644 --- a/resources/tester/helper_323_9_twin_cards_3d.cjs +++ b/resources/tester/helper_323_9_twin_cards_3d.cjs @@ -1,4 +1,4 @@ -/* PLAN 323-9 ④-② — 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체는 한 번. +/* 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. */ @@ -74,14 +74,19 @@ const kinds = (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.ownsBodies; +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)]); - pick.ownsBodies = () => true; // 고치기 전(측점마다 몸체) — 기준이 변경을 재는지 + const epTwin = kinds([one, ep(one)]); + const epFirst = kinds([ep(one), one]); // 차례가 바뀌어도 시설 자리에 가까운 카드 하나 + pick.ownsBody = () => true; // 고치기 전(카드마다 몸체) — 기준이 변경을 재는지 const before = kinds([one, clone(one)]); - pick.ownsBodies = own; - out[c] = { one: kinds([one]), twin, before }; + 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/test_323_9_twin_cards_3d.py b/resources/tester/test_323_9_twin_cards_3d.py index 7c72ea64b..b1950230c 100644 --- a/resources/tester/test_323_9_twin_cards_3d.py +++ b/resources/tester/test_323_9_twin_cards_3d.py @@ -1,6 +1,6 @@ -"""PLAN 323-9 ④-② — 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체가 둘 서던 흠. +"""PLAN 323-9 ④-② — 같은 시설을 그리는 카드 둘(같은 m · 노선 끝 EP 꼴 0.01 m 차)이면 3D 몸체가 둘 서던 흠. -3D 몸체는 구조물(관) 하나에 한 번 — 같은 m 카드 둘이면 앞 카드만 세움(`ownsBodies`). +3D 몸체는 시설(관 · 세월교 · BOX) 하나에 한 번 — 시설 자리에 가장 가까운 카드만 세움(`ownsBody`). Node 헬퍼 = `helper_323_9_twin_cards_3d.cjs`(73-4 픽스처 · 진짜 `buildCorridorStructures`). """ @@ -38,5 +38,8 @@ 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 From 4baf49ba4804657d23adf831438f51d61838fe2d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 21:34:05 +0900 Subject: [PATCH 27/27] =?UTF-8?q?chore(master):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=8B=9C=20=E2=80=94=20A=EA=B5=B0=20?= =?UTF-8?q?=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=98=B5=EC=85=98=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EC=95=94=EA=B1=B0=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?3=20m=20=C2=B7=20=EC=95=94=EA=B1=B0=20=EA=B2=BD=EC=82=AC=203=20?= =?UTF-8?q?%(=EC=9A=94=EA=B5=AC=20223=20=C2=B7=20M02=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EA=B8=B8=20=C2=B7=20=ED=8C=90=2021=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=8C=90=5F20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- resources/master_template/구조물정의.json | 210 +- .../구조물정의_이력/판_20.json | 3819 +++++++++++++++++ 2 files changed, 3823 insertions(+), 206 deletions(-) create mode 100644 resources/master_template/구조물정의_이력/판_20.json 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" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +}