From a1d63ebfd63b65fe0d11de1e1449030590186f46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 16:48:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=83=81=EC=84=B8=20=EC=B0=BD=20C?= =?UTF-8?q?AD=20=EC=B9=B8=20=ED=91=9C=20=EC=95=84=EB=9E=98=20=EC=A0=91?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=A4=84=20=EB=AF=B8=EB=A6=AC=20=EB=84=A3=EC=9D=8C?= =?UTF-8?q?(265-1=20=C2=B7=20265-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 창 차례 = 그림 → 표 → CAD 칸 · 칸 머리 한 줄 접기(공용 ui-collapsible) · 열 때 일반 측점 접힘 · 구조물 측점 폄 · 처음 펼 때 칸 만듦 - 미리 넣는 글 = 그 측점 구조물(관 지점 · 구조물 배치 기준 측점)마다 지정된 구조물 조합 이름 줄 · 미지정 = 「이름 조합 미지정」 · 배관 = 원형 마크(본체 줄 없음) · 옛 관보호공 · 관유출부보강 짜 맞춘 글 버림 - B07 짝(CulvertNote.py) 같은 규칙 · 정본 structures.json · 구조물정의 이름까지 읽음 - 시험 test_265_note_cad 넓힘 · test_260 · test_150 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- B06_Section/B06_Section_UI_Cross_Detail.ts | 60 +++- .../B06_Section_UI_Cross_Detail_Cad.ts | 194 ++++++++----- .../B06_Section_UI_Style_Cross_Detail.css | 16 +- ...B07_DesignDetail_Engine_Cad_CulvertNote.py | 259 ++++++++++-------- .../B07_DesignDetail_Router_Support.py | 17 +- resources/tester/helper_260_note_cad.cjs | 60 ++-- resources/tester/test_150_detail_pick.py | 3 +- resources/tester/test_260_note_cad.py | 155 ++++++----- resources/tester/test_265_note_cad.py | 190 ++++++++++++- 9 files changed, 647 insertions(+), 307 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index b82ab5469..7c7420e92 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -4,10 +4,11 @@ * * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · * 칸 크기가 바뀌면 다시 그림) · - * 가운데 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 → - * [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) · - * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · + * 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). + * 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 → + * [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) · + * 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). * 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) — * 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3). @@ -38,7 +39,12 @@ import { import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; -import { mountNoteCad, stationNoLabel } from "./B06_Section_UI_Cross_Detail_Cad"; +import { + loadNoteThings, + mountNoteCad, + stationNoLabel, + thingsAt, +} from "./B06_Section_UI_Cross_Detail_Cad"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { @@ -286,26 +292,50 @@ export function openCrossDetail(section: CrossSection): void { dialogClass: "b06-detail", wide: true, mount(body, close) { - // 표 위 빈 CAD(260-2) — 높이 고정 · 한 번만 만듦 · 닫으면 destroy + // 표 위 빈 CAD(260-2) — 높이 고정 · 닫으면 destroy · 머리 한 줄 접기(265-1 · 공용 접기 꼴) · + // 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 · + // 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림) + const noteBox = document.createElement("section"); + noteBox.className = "b06-detail__notebox ui-collapsible"; + const noteHead = document.createElement("div"); + noteHead.className = "b06-detail__notehead ui-collapsible__title"; + noteHead.textContent = "표 위 CAD"; const note = document.createElement("div"); note.className = "b06-detail__note"; note.style.height = "180px"; + noteBox.append(noteHead, note); const routeId = host?.routeId(); const stations = host?.detail()?.longitudinal.stations ?? []; - const noteCad = - host && routeId !== null && routeId !== undefined - ? mountNoteCad(note, section, () => state.section, { - projectId: host.projectId, - routeId, - station: stationNoLabel(section.chainage_m, inferStationInterval(stations)), - }) - : null; - note.querySelector(".cad-host")?.style.setProperty("min-height", "0"); + let noteCad: { destroy: () => void } | null = null; + const openNote = (): void => { + if (noteCad || !host || routeId === null || routeId === undefined) return; + noteCad = mountNoteCad(note, section, () => state.section, { + projectId: host.projectId, + routeId, + station: stationNoLabel(section.chainage_m, inferStationInterval(stations)), + things, + }); + note.querySelector(".cad-host")?.style.setProperty("min-height", "0"); + }; + let touched = false; + noteHead.addEventListener("click", () => { + touched = true; + if (!noteBox.classList.toggle("is-collapsed")) openNote(); + }); + // 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로) + noteBox.classList.add("is-collapsed"); + const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]); + void things.then((list) => { + if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return; + noteBox.classList.remove("is-collapsed"); + openNote(); + }); const cad = document.createElement("div"); cad.className = "b06-detail__cad"; const tableBox = document.createElement("div"); tableBox.className = "b06-detail__table"; - body.append(cad, note, tableBox); + // 차례 = 그림 → 표 → CAD 칸(265-1) + body.append(cad, tableBox, noteBox); // 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) · // 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3) diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index cf3fff35c..d7edda981 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -1,15 +1,16 @@ /* ============================================================================= * B06_Section_UI_Cross_Detail_Cad.ts - * 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21). + * 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1). * * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 * (`_Engine_Cad_Table.cross_note_cad_entities`). - * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수 - * 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면. - * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` · - * `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 · - * 거울 시험 `test_260_note_cad.py`. + * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음 + * (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 = + * 「이름 조합 미지정」) · 그 밖 = 빈 도면. + * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` · + * `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 + * 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`. * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. * ========================================================================== */ @@ -18,7 +19,18 @@ import { readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; -import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import { + fetchStructures, + fetchStructureTypes, + readPendingStructures, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { + readGroupItems, + readSummaryItem, + summaryLabel, + type SummaryItem, +} from "../common_util/common_util_summary_item"; +import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch"; import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const"; const noteKey = (chainageM: number): string => chainageM.toFixed(2); @@ -105,45 +117,11 @@ export function legacyNoteEntities(note: string | undefined): Record - typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; -const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : ""); /** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */ const wholeMetres = (value: number | null | undefined): string => typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : ""; -const trim = (value: number): string => String(Math.round(value * 100) / 100); -/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */ -const bondOf = (form: string | null | undefined): string => - /찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? ""); -const BASIN_FORMS: Record = { I: "I형", L: "L형", U: "ㄷ형" }; - -/** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */ -function sideLine(side: CulvertSideSpec | undefined, title: string): string | null { - if (!side?.structure) return null; - const basin = side.structure === "집수정"; - const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form); - const height = basin ? side.basin_height_m : side.revet_height_m; - const length = basin ? side.basin_length_m : side.revet_length_m; - const spec = [form, `H=${one(height)}`].filter(Boolean).join(","); - return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`; -} - -/** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */ -function chuteLine(options: Record, fallback?: string): string | null { - const picked = Object.keys(options) - .map((key) => /^chute_add_(\d+)_structure$/.exec(key)) - .filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim()) - .sort((a, b) => Number(a[1]) - Number(b[1]))[0]; - const head = picked ? `chute_add_${picked[1]}_` : null; - const structure = head ? String(options[`${head}structure`]).trim() : fallback; - if (!structure) return null; - const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : ""; - const area = head ? Number(options[`${head}area`]) : NaN; - const spec = [structure, bond].filter(Boolean).join(","); - return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`; -} /** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */ export function stationNoLabel(chainageM: number, intervalM: number): string { @@ -157,6 +135,42 @@ export function stationNoLabel(chainageM: number, intervalM: number): string { return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`; } +/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */ +export interface NoteThing { + name: string; + /** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */ + pipe: boolean; + /** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */ + at: number; + summary_item?: unknown; + group_items?: unknown; +} + +/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */ +const PIPE_BODY_LINK = "grv6lny"; + +/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */ +export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] => + things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M); + +/** + * 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 + + * 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 · + * 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만. + */ +export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] { + return thingsAt(things, chainageM).flatMap((thing) => { + const own = readSummaryItem(thing.summary_item); + const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items)); + if (own) slots.unshift(["", own]); + if (!slots.length) return thing.pipe ? [] : [`${thing.name} 조합 미지정`]; + const names = slots + .filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK))) + .map(([, item]) => summaryLabel(item)); + return [...new Set(names)]; + }); +} + function shape(type: string, shapeData: Record): Record { return { id: newId(), @@ -168,17 +182,9 @@ function shape(type: string, shapeData: Record): Record = {}, -): Record[] { +/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · + * 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) + 지시선. */ +function pipeMark(section: CrossSection): Record[] { const culvert = section.culvert; if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return []; if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M) @@ -189,12 +195,6 @@ export function culvertNoteEntities( `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, `L=${wholeMetres(section.design?.pipe_length_m)}`, ]; - const right = [ - station, - sideLine(culvert.inlet, "유입"), - sideLine(culvert.outlet, "유출"), - chuteLine(options, culvert.chute?.structure), - ].filter((line): line is string => !!line); return [ shape("Circle", { center: { x: cx, y: cy }, radius }), shape("Line", { @@ -204,29 +204,73 @@ export function culvertNoteEntities( ...inside.map((line, index) => textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"), ), - ...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)), ]; } -/** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */ -async function pipeOptionsAt( - projectId: string, - chainageM: number, -): Promise> { - const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points; - const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M); - return pipe?.options ?? {}; +/** + * 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) · + * 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록. + */ +export function noteEntities( + section: CrossSection, + station: string, + lines: readonly string[], +): Record[] { + const mark = pipeMark(section); + if (!mark.length && !lines.length) return []; + const x = mark.length ? 15 : 0; + return [ + ...mark, + ...[station, ...lines].map((line, index) => + textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR), + ), + ]; } -/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */ +/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) · + * 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */ +export async function loadNoteThings(projectId: string): Promise { + try { + const [pipes, structures, types] = await Promise.all([ + readPendingPipes(projectId) ?? + fetchDetailPipePoints(projectId).then((res) => res.pipe_points), + readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures), + fetchStructureTypes(true), + ]); + const names = new Map(types.map((type) => [type.type_id, type.name])); + const nameOf = (kind: string): string => names.get(kind) ?? kind; + const piped = pipes.map((point): NoteThing => { + const kind = point.facility ?? "pipe"; + const { summary_item, group_items } = point; + return { + name: nameOf(kind), + pipe: kind === "pipe", + at: point.chainage_m, + summary_item, + group_items, + }; + }); + const placed = structures.flatMap((item): NoteThing[] => { + const at = item.chainage_m ?? item.start_m; + if (typeof at !== "number" || !Number.isFinite(at)) return []; + const { summary_item, group_items } = item; + return [{ name: nameOf(item.type_id), pipe: false, at, summary_item, group_items }]; + }); + return [...piped, ...placed]; + } catch { + return []; + } +} + +/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면. + * 옮기거나 넣었으면 초안에 씀. */ async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise { const stored = noteCadOf(section, at); if (stored) return stored; let entities = legacyNoteEntities(section.design?.structure_note); - if (!entities.length && section.culvert) { - const owner = section.culvert.chainage_m ?? section.chainage_m; - const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({})); - entities = culvertNoteEntities(section, at.station, options); + if (!entities.length) { + const lines = structureNoteLines(await at.things, section.chainage_m); + entities = noteEntities(section, at.station, lines); } const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] }; if (entities.length) writeNoteCad(section, cad, at); @@ -240,10 +284,12 @@ export interface NoteCadPlace { routeId: number; /** 미리 넣음 첫 줄(`stationNoLabel`). */ station: string; + /** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */ + things: Promise; } /** - * [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · + * [상세] 모달 아래 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · * 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤). */ export function mountNoteCad( diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index f9507d873..3e3e0de14 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -1,6 +1,6 @@ /* ============================================================================= * B06_Section_UI_Style_Cross_Detail.css - * 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → 글 칸 → B07 수량표. + * 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → B07 수량표 → CAD 칸(265-1). * ========================================================================== */ /* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */ @@ -18,6 +18,20 @@ overflow: hidden; } +/* 표 위 CAD 칸(265-1) — 표 아래 · 머리 한 줄 접기(공용 `ui-collapsible` · 접히면 그림이 남은 높이) */ +.b06-detail__notebox { + flex: none; + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); +} + +.b06-detail__notehead { + font-size: var(--text-body-sm); + font-weight: 500; + color: var(--color-text); +} + .b06-detail__note { flex: none; width: 100%; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py index 18c584202..dc2fe6463 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -1,10 +1,13 @@ -"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21). +"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5). -⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` · -`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`. +⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` · +`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` · +`test_265_note_cad.py`. B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸 (빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저 (모달이 처음 열 때 옮기는 차례와 같음). +그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된 +구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」). 좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. """ @@ -14,16 +17,26 @@ import re 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 load_structure_types 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 -# TS `PIPE_MATCH_M`(`design_selection`) — 관 자리와 측점이 같은 자리로 보는 폭 +# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 PIPE_MATCH_M = 0.51 _LAYER_ID = "기본" _FONT = 2.0 _LINE = 3.0 _COLOR = "#ff7f7f" -_BASIN_FORMS = {"I": "I형", "L": "L형", "U": "ㄷ형"} +# 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리 +_PIPE_BODY_LINK = "grv6lny" +_LAYERS = ("system", "company", "personal") +_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$") + +#: (관 누가거리, 세트, 옵션) — 원형 마크 재료 +Pipe = tuple[float, dict[str, Any], dict[str, Any]] +#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items}` — TS `NoteThing` +Thing = dict[str, Any] def _js_round(value: float) -> int: @@ -31,74 +44,13 @@ def _js_round(value: float) -> int: return math.floor(value + 0.5) -def _one(value: Any) -> str: - ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) - return f"{value:.1f}" if ok else "" - - -def _metres(value: Any) -> str: - return f"{_one(value)}m" if _one(value) else "" +def _number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) def _whole_metres(value: Any) -> str: """관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값).""" - return f"{_js_round(value)}m" if _one(value) else "" - - -def _trim(value: float) -> str: - rounded = _js_round(value * 100) / 100 - return str(int(rounded)) if rounded == int(rounded) else str(rounded) - - -def _bond(form: Any) -> str: - """돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로.""" - text = str(form) if form is not None else "" - return "찰" if "찰" in text else "메" if "메" in text else text - - -def _side_line(side: Any, title: str) -> str | None: - if not isinstance(side, dict) or not side.get("structure"): - return None - basin = side["structure"] == "집수정" - form = ( - _BASIN_FORMS.get(side.get("basin_form") or "", "") - if basin - else _bond(side.get("revet_form")) - ) - height = side.get("basin_height_m" if basin else "revet_height_m") - length = side.get("basin_length_m" if basin else "revet_length_m") - spec = ",".join(part for part in (form, f"H={_one(height)}") if part) - return f"관보호공({title}) {side['structure']}({spec}) L={_metres(length)}" - - -def _area(value: Any) -> float: - """JS `Number()` 꼴 — 빈 글 = 0 · 못 읽음 = nan.""" - if value is None: - return math.nan - if isinstance(value, str) and not value.strip(): - return 0.0 - try: - return float(value) - except (TypeError, ValueError): - return math.nan - - -def _chute_line(options: dict[str, Any], fallback: str | None) -> str | None: - picked = sorted( - int(hit.group(1)) - for key, value in options.items() - if (hit := re.match(r"^chute_add_(\d+)_structure$", key)) - and str(value if value is not None else "").strip() - ) - head = f"chute_add_{picked[0]}_" if picked else None - structure = str(options[f"{head}structure"]).strip() if head else fallback - if not structure: - return None - bond_raw = options.get(f"{head}bond") if head else None - bond = _bond(bond_raw if bond_raw is not None else "") if head else "" - area = _area(options.get(f"{head}area")) if head else math.nan - spec = ",".join(part for part in (structure, bond) if part) - return f"관유출부보강({spec}) {_trim(area) if area > 0 else ''}m2" + return f"{_js_round(value)}m" if _number(value) else "" def station_no_label(chainage_m: float, interval_m: float) -> str: @@ -112,6 +64,59 @@ def station_no_label(chainage_m: float, interval_m: float) -> str: return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}" +def _summary_item(value: Any) -> dict[str, Any] | None: + """TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None.""" + if not isinstance(value, dict): + return None + owner, key = value.get("owner"), value.get("key") + ok = ( + value.get("layer") in _LAYERS + and (owner is None or isinstance(owner, str)) + and isinstance(key, str) + and key != "" + and isinstance(value.get("name"), str) + ) + return value if ok else None + + +def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]: + """TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로).""" + if not isinstance(value, dict): + return [] + return [ + (gid, item) + for gid, raw in value.items() + if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw)) + ] + + +def things_at(things: list[Thing], chainage_m: float) -> list[Thing]: + """그 측점에 놓인 구조물 — TS `thingsAt`.""" + return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M] + + +def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]: + """구조물 조합 줄 — TS `structureNoteLines` 짝. + + 본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌. + """ + lines: list[str] = [] + for thing in things_at(things, chainage_m): + own = _summary_item(thing.get("summary_item")) + slots = ([("", own)] if own else []) + _group_items(thing.get("group_items")) + if not slots: + if not thing["pipe"]: + lines.append(f"{thing['name']} 조합 미지정") + continue + names = [ + item["name"] or item["key"] + for gid, item in slots + if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK)) + ] + lines += list(dict.fromkeys(names)) + return lines + + def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]: return { "id": f"culvert-note:{index}", @@ -141,17 +146,10 @@ def _text(index: int, label: str, x: float, y: float, align: str = "left") -> di ) -def culvert_note_entities( - chainage_m: float, - culvert: dict[str, Any] | None, - design: dict[str, Any] | None, - station: str, - options: dict[str, Any] | None = None, +def _pipe_mark( + chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None ) -> list[dict[str, Any]]: - """횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록. - - `culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션. - """ + """원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음).""" if not isinstance(culvert, dict) or culvert.get("type") != "pipe": return [] if culvert.get("body") or culvert.get("hidden_pipe"): @@ -166,18 +164,7 @@ def culvert_note_entities( f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m", f"L={_whole_metres(length)}", ] - chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {} - right = [ - line - for line in ( - station, - _side_line(culvert.get("inlet"), "유입"), - _side_line(culvert.get("outlet"), "유출"), - _chute_line(options or {}, chute.get("structure")), - ) - if line - ] - entities = [ + return [ _shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}), _shape( 1, @@ -187,38 +174,84 @@ def culvert_note_entities( "endPoint": {"x": cx, "y": cy - radius - _LINE}, }, ), - ] - entities += [ - _text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside) - ] - entities += [_text(5 + i, line, 15, -_LINE * (i + 0.5)) for i, line in enumerate(right)] - return entities + ] + [_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)] -def load_pipe_notes(project_root: Path) -> list[tuple[float, dict[str, Any], dict[str, Any]]]: - """정본 `pipe_points.json` 의 배수관 — (관 누가거리, 세트, 옵션) · 관경 안 고름 = 관 없음.""" - path = pipe_points_file(project_root) - try: - document = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): +def note_entities( + chainage_m: float, + culvert: dict[str, Any] | None, + design: dict[str, Any] | None, + station: str, + lines: list[str], +) -> list[dict[str, Any]]: + """미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터). + + `culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`. + """ + mark = _pipe_mark(chainage_m, culvert, design) + if not mark and not lines: return [] - pipes = [] - for point in parse_pipe_points(document.get("points")): + x = 15 if mark else 0 + start = len(mark) + return mark + [ + _text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines]) + ] + + +def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]: + """글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례).""" + things: list[Thing] = [ + { + "name": names.get(point.facility, point.facility), + "pipe": point.facility == PIPE_FACILITY_PIPE, + "at": float(point.chainage_m), + "summary_item": point.summary_item, + "group_items": point.group_items, + } + for point in points + ] + for item in structures: + at = item.chainage_m if item.chainage_m is not None else item.start_m + if not _number(at): + continue + things.append( + { + "name": names.get(item.type_id, item.type_id), + "pipe": False, + "at": float(at), + "summary_item": item.summary_item, + "group_items": item.group_items, + } + ) + return things + + +def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]: + """정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치).""" + try: + document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8")) + points = parse_pipe_points(document.get("points")) + except (OSError, json.JSONDecodeError): + points = [] + pipes: list[Pipe] = [] + for point in points: options = dict(point.options or {}) if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""): continue spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)} pipes.append((float(point.chainage_m), spec, options)) - return pipes + _revision, structures = load_structures(str(project_root)) + names = {kind.type_id: kind.name for kind in load_structure_types()} + return pipes, _things(points, structures, names) -def with_culvert_note( +def with_structure_note( design: dict[str, Any] | None, chainage_m: float, - pipes: list[tuple[float, dict[str, Any], dict[str, Any]]], + sources: tuple[list[Pipe], list[Thing]], station: str, ) -> dict[str, Any] | None: - """CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로. + """CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로. 설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈. """ @@ -226,11 +259,11 @@ def with_culvert_note( return design if str(design.get("structure_note") or "").strip(): return design + pipes, things = sources near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M] - if not near: - return design - _at, spec, options = min(near, key=lambda pipe: abs(pipe[0] - chainage_m)) - entities = culvert_note_entities(chainage_m, spec, design, station, options) + spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None + lines = structure_note_lines(things, chainage_m) + entities = note_entities(chainage_m, spec, design, station, lines) if not entities: return design return {**design, "structure_note_cad": {"entities": entities}} diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 17056cc23..b6cb11762 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -26,8 +26,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( - load_pipe_notes, - with_culvert_note, + Pipe, + Thing, + load_note_sources, + with_structure_note, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( station_no_label as station_no_label_upper, @@ -390,11 +392,12 @@ def _cross_section_input( chainage: int | float, design: dict[str, Any] | None, overlay: tuple[float, float, float, float] | None = None, - pipes: list[tuple[float, dict[str, Any], dict[str, Any]]] | None = None, + pipes: tuple[list[Pipe], list[Thing]] | None = None, ) -> dict[str, Any] | None: """한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다. - `pipes` = 정본 배수관(`load_pipe_notes`) — CAD 칸 없는 주인 측점에 미리 넣음(260-4).""" + `pipes` = 정본 재료(`load_note_sources` · 배수관 · 구조물) — CAD 칸 없는 측점에 + 미리 넣음(260-4 · 265-2).""" path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage) if not path.is_file(): return None @@ -405,7 +408,9 @@ def _cross_section_input( "chainage": chainage, "source": source, # 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4) - "design": with_culvert_note(design, at, pipes or [], station_no_label_upper(at, interval)), + "design": with_structure_note( + design, at, pipes or ([], []), station_no_label_upper(at, interval) + ), "design_line": _cross_design_line(longitudinal_path, source, design), "quantity_table": _quantity_table(source, design), "title": station_no_label(float(source.get("chainage_m", chainage)), interval), @@ -428,7 +433,7 @@ def _read_cross_sheet( if sheet is None: raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.") longitudinal = _read_json(longitudinal_path) - pipes = load_pipe_notes(project_root) + pipes = load_note_sources(project_root) sections = [ section for section in ( diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs index bf9ff6244..eeceaeff0 100644 --- a/resources/tester/helper_260_note_cad.cjs +++ b/resources/tester/helper_260_note_cad.cjs @@ -7,12 +7,21 @@ const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); +// 인자 `load` = 표준입력 {pipes, structures, types} 를 세션 · 서버 흉내로 `loadNoteThings`(265-2) +const DATA = { pipes: [], structures: [], types: [] }; const STUBS = { "@ui/cad_host/cad_host": { createCadHost: () => ({}) }, "../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined }, "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, - "../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({}) }, + "../B04_PreProcess/B04_PreProcess_Api_Fetch": { + fetchDetailPipePoints: async () => ({ pipe_points: DATA.pipes }), + }, "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null }, + "../B05_Profile/B05_Profile_Api_Structures": { + fetchStructures: async () => ({ structures: DATA.structures }), + fetchStructureTypes: async () => DATA.types, + readPendingStructures: () => null, + }, }; function loadTs(file) { @@ -28,10 +37,13 @@ function loadTs(file) { return box.exports; } -// 관 길이 정수(265-3) = 실제 `pipeLengthWhole`(타입 import 만 있는 파일) +// 관 길이 정수(265-3) = 실제 `pipeLengthWhole` · 조합 참조 읽기(265-2) = 실제(둘 다 import 없음) STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs( path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"), ); +STUBS["../common_util/common_util_summary_item"] = loadTs( + path.join(ROOT, "common_util", "common_util_summary_item.ts"), +); const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); const wall = (role, form, height, length) => ({ @@ -58,7 +70,6 @@ const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({ culvert: spec, design, }); -const CHUTE = { chute_add_1_structure: "돌붙임", chute_add_1_bond: "찰붙임", chute_add_1_area: 10 }; /** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */ const brief = (entities) => @@ -71,11 +82,12 @@ const brief = (entities) => color: entity.lineColor, layer: entity.layerId, })); -const run = (sec, options) => brief(cad.culvertNoteEntities(sec, "NO.71+13", options)); +const run = (sec, lines = []) => brief(cad.noteEntities(sec, "NO.71+13", lines)); +const LINES = ["기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"]; const out = { - full: run(section(culvert()), CHUTE), - noLength: run(section(culvert(), {}), {}), + full: run(section(culvert()), LINES), + noLength: run(section(culvert(), {})), basin: run( section( culvert({ @@ -88,9 +100,9 @@ const out = { }, }), ), - {}, + [], ), - chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), {}), + chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), []), noSides: run( section( culvert({ @@ -98,12 +110,14 @@ const out = { outlet: { role: "outlet", structure: "" }, }), ), - {}, + [], ), - none: run(section(undefined), CHUTE), - ford: run(section(culvert({ body: "ford" })), CHUTE), - hidden: run(section(culvert({ hidden_pipe: true })), CHUTE), - neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440), CHUTE), + none: run(section(undefined)), + ford: run(section(culvert({ body: "ford" }))), + hidden: run(section(culvert({ hidden_pipe: true }))), + neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440)), + // 마크 없이 조합 줄만(구조물 측점 · 265-2) = 측점부터 왼쪽 끝 + linesOnly: run(section(undefined), LINES), legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")), legacyEmpty: cad.legacyNoteEntities(" ").length, station: [ @@ -112,13 +126,21 @@ const out = { cad.stationNoLabel(1439.97, 20), ], }; -// 거울(260-4) — 시험이 표준입력으로 준 같은 입력 [{section, station, options, chainage, interval}] -// → TS 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조) +// 거울(260-4 · 265-2) — 시험이 표준입력으로 준 같은 입력 [{section, station, things, chainage, interval}] +// → TS 조합 줄 · 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조) if (process.argv[2] === "mirror") { const cases = JSON.parse(fs.readFileSync(0, "utf8")); - const mirrored = cases.map((item) => ({ - entities: cad.culvertNoteEntities(item.section, item.station, item.options), - station: cad.stationNoLabel(item.chainage, item.interval), - })); + const mirrored = cases.map((item) => { + const lines = cad.structureNoteLines(item.things ?? [], item.section.chainage_m); + return { + lines, + entities: cad.noteEntities(item.section, item.station, lines), + station: cad.stationNoLabel(item.chainage, item.interval), + at: cad.thingsAt(item.things ?? [], item.section.chainage_m).length, + }; + }); process.stdout.write(JSON.stringify(mirrored)); +} else if (process.argv[2] === "load") { + Object.assign(DATA, JSON.parse(fs.readFileSync(0, "utf8"))); + cad.loadNoteThings("p").then((things) => process.stdout.write(JSON.stringify(things))); } else process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index 717cf926c..4019e8a86 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -93,7 +93,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서(): with open(os.path.join(base, "B06_Section_UI_Style_Cross_Detail.css"), encoding="utf-8") as f: css = f.read().replace(chr(13), "") assert "wide: true" in src and "backdropClass" not in src - assert "body.append(cad, note, tableBox)" in src + # 265-1 — 그림 → 표 → CAD 칸(글 칸 자리) + assert "body.append(cad, tableBox, noteBox)" in src # 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none backdrop = css.split(".b06-detail__pick-outline {")[0] assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 34aae9442..3124c0dbb 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -2,8 +2,9 @@ """PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위. - 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch. -- 260-2 미리 넣음: 횡단배수 주인 측점만 · 관 · 유입 · 유출 · 도수로 · 빈 구조물 · 옛 글 옮김 - (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림). +- 260-2 미리 넣음: 횡단배수 주인 측점 원형 마크 · 빈 구조물 · 옛 글 옮김 + (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림) · 오른쪽 글 = 구조물 조합 줄(265-2 · + `test_265_note_cad.py`) — 옛 관보호공 · 관유출부보강 짜 맞춘 글은 버림. ./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q """ @@ -101,7 +102,7 @@ def _labels(entities: list[dict]) -> list[str]: def test_관_유입_유출_도수로_한_벌(out): - """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 관보호공 둘 · 관유출부보강.""" + """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 구조물 조합 줄(265-2).""" full = out["full"] assert [entity["type"] for entity in full[:2]] == ["Circle", "Line"] assert _labels(full) == [ @@ -109,30 +110,32 @@ def test_관_유입_유출_도수로_한_벌(out): "Φ1200m/m", "L=22m", "NO.71+13", - "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", - "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", - "관유출부보강(돌붙임,찰) 10m2", + "기슭막이 찰쌓기 2.0m", + "돌붙임 찰붙임", ] # 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층 inside, right = full[2:5], full[5:] assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])} - assert [e["y"] for e in right] == [-1.5, -4.5, -7.5, -10.5] + assert [e["y"] for e in right] == [-1.5, -4.5, -7.5] + assert {e["x"] for e in right} == {15} assert {e["align"] for e in right} == {"left"} assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"} def test_관_길이_없으면_L_뒤_빈칸(out): assert _labels(out["noLength"])[2] == "L=" - # 도수로 상자가 없으면 관유출부보강 줄 없음 - assert not any(label.startswith("관유출부보강") for label in _labels(out["noLength"])) + # 조합 줄이 없으면 측점 줄까지 + assert _labels(out["noLength"])[3:] == ["NO.71+13"] def test_유입_집수정_은_형태_높이_길이(out): - assert "관보호공(유입) 집수정(L형,H=1.7) L=2.0m" in _labels(out["basin"]) + # 265-2 — 보호공 짜 맞춘 글 버림(조합 줄로) · 원형 마크 · 측점만 + assert _labels(out["basin"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] def test_도수로_옵션이_없으면_세트_구조만(out): - assert _labels(out["chuteFallback"])[-1] == "관유출부보강(도수로) m2" + # 265-2 — 관유출부보강 짜 맞춘 글 버림 + assert not any(label.startswith("관유출부보강") for label in _labels(out["chuteFallback"])) def test_보호공을_안_고르면_관과_측점만(out): @@ -141,10 +144,16 @@ def test_보호공을_안_고르면_관과_측점만(out): @pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) def test_횡단배수_주인_측점이_아니면_빈값(out, case): - """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · 이웃 측점 = 빈 도면.""" + """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관 · 이웃 측점 = 빈 도면(줄 없음).""" assert out[case] == [] +def test_마크_없이_조합_줄만이면_측점부터_왼쪽_끝(out): + lines = out["linesOnly"] + assert _labels(lines) == ["NO.71+13", "기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"] + assert {e["x"] for e in lines} == {0} and [e["y"] for e in lines] == [-1.5, -4.5, -7.5] + + def test_옛_글은_줄마다_Text_로_옮김(out): legacy = out["legacy"] assert _labels(legacy) == ["L형 측구", "집수정 보강"] @@ -300,10 +309,11 @@ def test_CAD_칸이_없으면_옛_글_줄_그대로(): from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 - culvert_note_entities, - load_pipe_notes, + load_note_sources, + note_entities, station_no_label, - with_culvert_note, + structure_note_lines, + with_structure_note, ) _OPTIONS = { @@ -323,36 +333,44 @@ _OPTIONS = { _NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")} -def _case(options: dict, pipe_at: float, chainage: float, design: dict | None, **spec) -> dict: +def _item(key: str, name: str) -> dict: + return {"layer": "system", "owner": None, "key": key, "name": name} + + +# 관 조합 — 본체(「관」) · 유입 · 유출 · 도수로(265-2 · 본체 줄은 원형 마크가 대신) +_PIPE_THING = { + "name": "배관", + "pipe": True, + "at": 1433.0, + "group_items": { + "grv6lny": _item("SS000001", "흄관 1200"), + "gnbf9m9": _item("SS000055", "기슭막이 메쌓기 1.2m 메쌓기 기초무"), + "gchuteadd1": _item("SS000070", "돌붙임 찰붙임"), + }, +} + + +def _case( + options: dict, pipe_at: float, chainage: float, design: dict | None, things=(), **spec +) -> dict: culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec} return { "section": {"chainage_m": chainage, "culvert": culvert, "design": design}, "station": station_no_label(chainage, 20.0), - "options": options, + "things": list(things), "chainage": chainage, "interval": 20.0, } _MIRROR_CASES = [ - _case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}), + _case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}, [_PIPE_THING]), _case(_NO_CHUTE, 1433.0, 1433.0, {}), - _case( - { - **_NO_CHUTE, - "inlet_type": "집수정", - "inlet_structure": "L", - "inlet_basin_height_m": 1.7, - "inlet_basin_length_m": 2, - }, - 1433.0, - 1433.0, - {"pipe_length_m": 9.5}, - ), - _case({**_NO_CHUTE, "chute_add_2_structure": "도수로", "chute_add_2_area": ""}, 80, 80, None), - _case({**_OPTIONS, "pipe_diameter_mm": 800, "chute_add_1_area": 12.345}, 12.5, 12.5, {}), - _case({**_OPTIONS, "inlet_revet_form": "콘크리트"}, 440.241, 440.0, {"pipe_length_m": 7}), - _case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}), + _case(_NO_CHUTE, 1433.0, 1433.0, {"pipe_length_m": 9.5}), + _case(_NO_CHUTE, 80, 80, None, [{**_PIPE_THING, "at": 80.0}]), + _case({**_OPTIONS, "pipe_diameter_mm": 800}, 12.5, 12.5, {}, [{**_PIPE_THING, "at": 12.5}]), + _case(_OPTIONS, 440.241, 440.0, {"pipe_length_m": 7}, [{**_PIPE_THING, "at": 440.241}]), + _case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}, [_PIPE_THING]), _case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"), _case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True), ] @@ -362,33 +380,35 @@ def _without_id(entities: list[dict]) -> list[dict]: return [{k: v for k, v in entity.items() if k != "id"} for entity in entities] -def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티(): +def _mirror(cases: list[dict]) -> list[dict]: proc = subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], - input=json.dumps(_MIRROR_CASES, ensure_ascii=False), + input=json.dumps(cases, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", timeout=60, ) assert proc.returncode == 0, proc.stderr - mirrored = json.loads(proc.stdout) + return json.loads(proc.stdout) + + +def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티(): + mirrored = _mirror(_MIRROR_CASES) assert len(mirrored) == len(_MIRROR_CASES) for case, ts in zip(_MIRROR_CASES, mirrored, strict=True): section = case["section"] - py = culvert_note_entities( - section["chainage_m"], - section["culvert"], - section["design"], - case["station"], - case["options"], + lines = structure_note_lines(case["things"], section["chainage_m"]) + assert lines == ts["lines"], case["station"] + py = note_entities( + section["chainage_m"], section["culvert"], section["design"], case["station"], lines ) assert _without_id(py) == _without_id(ts["entities"]), case["station"] assert station_no_label(case["chainage"], case["interval"]) == ts["station"] # 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값 assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3 labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]] - assert "Φ800m/m" in labels and "관유출부보강(돌붙임,찰) 12.35m2" in labels + assert "Φ800m/m" in labels and "돌붙임 찰붙임" in labels and "흄관 1200" not in labels def _write_pipes(root: Path, points: list[dict]) -> None: @@ -406,10 +426,16 @@ def test_정본_배수관만_읽음(tmp_path): {"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}}, ], ) - pipes = load_pipe_notes(tmp_path) + pipes, things = load_note_sources(tmp_path) assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼 assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS - assert load_pipe_notes(tmp_path / "없음") == [] + # 글 줄 재료 = 관 지점 모두(측점 차례 · 시설 이름 = 구조물정의 종류 이름) + assert [(t["name"], t["pipe"], t["at"]) for t in things] == [ + ("배관", True, 80.0), + ("세월교", False, 90.0), + ("배관", True, 1433.0), + ] + assert load_note_sources(tmp_path / "없음") == ([], []) def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path): @@ -417,37 +443,38 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_ tmp_path, [{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}], ) - pipes = load_pipe_notes(tmp_path) + sources = load_note_sources(tmp_path) design = {"pipe_length_m": 22, "paved": False} - seeded = with_culvert_note(design, 1433.0, pipes, "NO.71+13") + seeded = with_structure_note(design, 1433.0, sources, "NO.71+13") assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜 labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]] - assert labels[2:] == [ - "배수관", - "Φ1200m/m", - "L=22m", - "NO.71+13", - "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", - "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", - "관유출부보강(돌붙임,찰) 10m2", - ] + # 관 조합 미지정 = 원형 마크 · 측점만(배관은 글 줄 없음) + assert labels[2:] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] cleared = {**design, "structure_note_cad": {"entities": []}} - assert with_culvert_note(cleared, 1433.0, pipes, "x") is cleared + assert with_structure_note(cleared, 1433.0, sources, "x") is cleared old = {**design, "structure_note": "옛 글"} - assert with_culvert_note(old, 1433.0, pipes, "x") is old - assert with_culvert_note(None, 1433.0, pipes, "x") is None - assert with_culvert_note(design, 1440.0, pipes, "x") is design + assert with_structure_note(old, 1433.0, sources, "x") is old + assert with_structure_note(None, 1433.0, sources, "x") is None + assert with_structure_note(design, 1440.0, sources, "x") is design def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path): _write_pipes( tmp_path, - [{"chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}], + [ + { + "chainage_m": 20.2, + "lonlat": [0, 0], + "facility": "pipe", + "options": _OPTIONS, + "group_items": {"g9lq9uk": _item("SS000055", "기슭막이 메쌓기 1.2m")}, + } + ], ) - design = with_culvert_note({"pipe_length_m": 22}, 20.0, load_pipe_notes(tmp_path), "NO.1") + design = with_structure_note({"pipe_length_m": 22}, 20.0, load_note_sources(tmp_path), "NO.1") drawn = _draw(design) labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"] - assert "NO.1" in labels and "관유출부보강(돌붙임,찰) 10m2" in labels + assert "NO.1" in labels and "기슭막이 메쌓기 1.2m" in labels plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"] drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"] assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15 diff --git a/resources/tester/test_265_note_cad.py b/resources/tester/test_265_note_cad.py index c92db042e..f476d1f74 100644 --- a/resources/tester/test_265_note_cad.py +++ b/resources/tester/test_265_note_cad.py @@ -1,6 +1,9 @@ # -*- coding: utf-8 -*- """PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수. +- 265-1 상세 창 차례 = 그림 → 표 → CAD 칸 · CAD 칸 접기(일반 측점 접힘 · 구조물 측점 폄). +- 265-2 미리 넣는 글 = 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 · 배관 = 원형 마크 + (TS = 파이썬 짝 · `helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림). - 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값). ./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q @@ -17,19 +20,23 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from B06_Section.B06_Section_Engine_Culvert import _culvert_set # noqa: E402 +from B05_Profile.B05_Profile_Structures_Repository import structures_file_path # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 - culvert_note_entities, + load_note_sources, + note_entities, + structure_note_lines, + with_structure_note, ) HERE = Path(__file__).resolve().parent _PIPE = {"pipe_diameter_mm": 800} -def _mirror(cases: list[dict]) -> list[dict]: +def _helper(mode: str, payload) -> list: proc = subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"], - input=json.dumps(cases, ensure_ascii=False), + ["node", str(HERE / "helper_260_note_cad.cjs"), mode], + input=json.dumps(payload, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", @@ -43,6 +50,168 @@ def _labels(entities: list[dict]) -> list[str]: return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"] +def _strip(entities: list[dict]) -> list[dict]: + return [{k: v for k, v in e.items() if k != "id"} for e in entities] + + +def _case(things: list, chainage: float, culvert: dict | None, design: dict) -> dict: + return { + "section": {"chainage_m": chainage, "culvert": culvert, "design": design}, + "station": "NO.2", + "things": things, + "chainage": chainage, + "interval": 20.0, + } + + +def _read(rel: str) -> str: + return (PROJECT_ROOT / rel).read_text(encoding="utf-8") + + +# ── 265-1 차례 · 접기 ────────────────────────────────────────────────── + + +def test_상세_창_차례는_그림_표_CAD_칸(): + detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts") + assert "body.append(cad, tableBox, noteBox);" in detail + # CAD 칸 = 공용 접기 꼴(머리 한 줄 · 캐럿) · 펼 때 한 번 만듦 + assert 'noteBox.className = "b06-detail__notebox ui-collapsible";' in detail + assert 'noteHead.className = "b06-detail__notehead ui-collapsible__title";' in detail + assert 'if (!noteBox.classList.toggle("is-collapsed")) openNote();' in detail + + +def test_열_때_기본_일반_측점_접힘_구조물_측점_폄(): + detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts") + start = detail.index('noteBox.classList.add("is-collapsed");') + rule = detail[start : detail.index("const cad = ", start)] + # 접힘으로 시작 → 그 측점에 놓인 구조물(`thingsAt`)이 있으면 폄 · 사용자가 먼저 누르면 그대로 + assert "thingsAt(list, section.chainage_m).length" in rule + assert 'noteBox.classList.remove("is-collapsed");' in rule and "touched" in rule + # 판정 = 놓인 구조물 수(구조물 측점 1 이상 · 일반 측점 0) — 아래 거울 시험 `at` + [structure, plain] = _helper( + "mirror", + [_case(_THINGS, 40.0, None, {}), _case(_THINGS, 20.0, None, {})], + ) + assert structure["at"] == 5 and plain["at"] == 0 + + +# ── 265-2 구조물 조합 줄 ─────────────────────────────────────────────── + +_A = {"layer": "system", "owner": None, "key": "SS000055", "name": "기슭막이 메쌓기 1.2m 기초무"} +_B = {"layer": "system", "owner": None, "key": "SS000070", "name": "돌붙임 찰붙임"} +_C = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "옹벽 콘크리트 H=2.0"} +_BODY = {"layer": "system", "owner": None, "key": "SS000001", "name": "흄관 1200"} +_THINGS = [ + # 배관 = 본체(옛 summary_item · 「관」 grv6lny) 줄 없음 · 보호공 · 도수로 조합만 + { + "name": "배관", + "pipe": True, + "at": 40.2, + "summary_item": _BODY, + "group_items": {"grv6lny": _BODY, "gnbf9m9": _A, "gchuteadd1": _B}, + }, + # 옹벽 = 본체 + 그룹(같은 조합 = 한 줄) + {"name": "옹벽", "pipe": False, "at": 40.0, "summary_item": _C, "group_items": {"gx": _C}}, + # 조합 미지정 구조물 + {"name": "돌쌓기(찰)", "pipe": False, "at": 39.6}, + # 미지정 배관(원형 마크가 대신 · 줄 없음) · 모양 틀린 참조 = 미지정 + {"name": "배관", "pipe": True, "at": 40.0, "group_items": {"bad id": _A}}, + {"name": "가드레일", "pipe": False, "at": 40.0, "summary_item": {"key": ""}}, + # 이웃 측점 구조물 = 뺌 + {"name": "옹벽", "pipe": False, "at": 40.6, "summary_item": _C}, +] +_LINES = [ + "기슭막이 메쌓기 1.2m 기초무", + "돌붙임 찰붙임", + "옹벽 콘크리트 H=2.0", + "돌쌓기(찰) 조합 미지정", + "가드레일 조합 미지정", +] + + +def test_구조물_여럿_글은_조합_이름_미지정_배관_본체_뺌_TS_파이썬_같음(): + culvert = {**_culvert_set(_PIPE), "chainage_m": 40.2} + cases = [ + _case(_THINGS, 40.0, culvert, {"pipe_length_m": 8.5}), + _case(_THINGS, 40.0, None, {}), + _case(_THINGS[5:], 40.0, None, {}), + ] + mirrored = _helper("mirror", cases) + for case, ts in zip(cases, mirrored, strict=True): + section = case["section"] + lines = structure_note_lines(case["things"], 40.0) + py = note_entities(40.0, section["culvert"], section["design"], "NO.2", lines) + assert ts["lines"] == lines + assert _strip(ts["entities"]) == _strip(py) + full, bare, none = mirrored + assert full["lines"] == _LINES + # 배관 = 원형 마크(원 안 배수관 · Φ · L 정수) · 오른쪽 측점 + 조합 줄 + assert [e["type"] for e in full["entities"][:2]] == ["Circle", "Line"] + assert _labels(full["entities"]) == ["배수관", "Φ800m/m", "L=9m", "NO.2", *_LINES] + assert "흄관 1200" not in _labels(full["entities"]) + # 원형 마크 없음(관 없는 구조물 측점) = 측점 + 줄 · 구조물 없는 측점 = 빈 도면 + assert _labels(bare["entities"]) == ["NO.2", *_LINES] + assert none["entities"] == [] and none["at"] == 0 + + +def test_글_줄_재료는_관_지점_구조물_배치_종류_이름(): + payload = { + "pipes": [ + {"chainage_m": 40.2, "facility": "pipe", "group_items": {"gnbf9m9": _A}}, + {"chainage_m": 60.0, "facility": "box_culvert"}, + ], + "structures": [ + {"type_id": "retaining_wall", "chainage_m": 40.0, "summary_item": _C}, + {"type_id": "guardrail", "start_m": 30.0, "end_m": 80.0}, + {"type_id": "mystery", "chainage_m": None, "start_m": None}, + ], + "types": [ + {"type_id": "pipe", "name": "배관"}, + {"type_id": "box_culvert", "name": "BOX암거"}, + {"type_id": "retaining_wall", "name": "옹벽"}, + {"type_id": "guardrail", "name": "가드레일"}, + ], + } + things = _helper("load", payload) + assert [(t["name"], t["pipe"], t["at"]) for t in things] == [ + ("배관", True, 40.2), + ("BOX암거", False, 60.0), + ("옹벽", False, 40.0), + ("가드레일", False, 30.0), # 구간형 = 시작 측점(기준 측점 없을 때) + ] + assert things[0]["group_items"] == {"gnbf9m9": _A} and things[2]["summary_item"] == _C + + +def test_파이썬_짝_재료는_정본_관_지점_구조물_배치(tmp_path): + path = pipe_points_file(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + point = {"chainage_m": 40.2, "lonlat": [0, 0], "facility": "pipe", "options": _PIPE} + point["group_items"] = {"gnbf9m9": _A} + path.write_text(json.dumps({"points": [point]}, ensure_ascii=False), encoding="utf-8") + spath = Path(structures_file_path(str(tmp_path))) + spath.parent.mkdir(parents=True, exist_ok=True) + wall = { + "structure_id": "w1", + "type_id": "retaining_wall", + "placement": "point", + "chainage_m": 40.0, + "options": {}, + "memo": "", + "placement_source": "manual", + "status": "draft", + "revision": 1, + "geometry": None, + "summary_item": _C, + } + doc = {"revision": 1, "structures": [wall]} + spath.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + sources = load_note_sources(tmp_path) + assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)] + seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2") + labels = _labels(seeded["structure_note_cad"]["entities"]) + assert labels == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"], _C["name"]] + + # ── 265-3 관 길이 정수 ───────────────────────────────────────────────── @@ -53,14 +222,7 @@ def _labels(entities: list[dict]) -> list[str]: def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label): design = {} if length is None else {"pipe_length_m": length} culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0} - case = { - "section": {"chainage_m": 40.0, "culvert": culvert, "design": design}, - "station": "NO.2", - "options": _PIPE, - "chainage": 40.0, - "interval": 20.0, - } - [ts] = _mirror([case]) - py = culvert_note_entities(40.0, culvert, design, "NO.2", _PIPE) + [ts] = _helper("mirror", [_case([], 40.0, culvert, design)]) + py = note_entities(40.0, culvert, design, "NO.2", []) assert _labels(ts["entities"])[2] == label assert _labels(py)[2] == label