From 29803ada06973aeabd00c9be125ce0860af148f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:07:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=91=9C=20=EC=9C=84=20=EB=B9=88=20CAD=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=84=A3=EC=9D=8C=20(260-1=20=C2=B7=20260-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 칸 design.structure_note_cad — 스키마 · 재계산 남김 · 세션 초안 structure-note-cads · [저장] patch - 상세 모달 글 상자 → 빈 CAD(cad_host bare · 높이 고정 · 닫으면 destroy · 고칠 때마다 초안) - 처음 열 때 칸이 없으면 옛 글 줄마다 Text · 없으면 횡단배수 주인 측점만 미리 넣음 - 시험 test_260_note_cad · helper_260_note_cad Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- A00_Common/b_page_state.ts | 2 + B06_Section/B06_Section_Api_Types.ts | 10 + B06_Section/B06_Section_Cross_Refresh.ts | 2 + B06_Section/B06_Section_Router_Design.py | 2 + B06_Section/B06_Section_Schema.py | 11 + B06_Section/B06_Section_UI_Cross_Detail.ts | 53 ++-- .../B06_Section_UI_Cross_Detail_Cad.ts | 286 ++++++++++++++++++ B06_Section/B06_Section_UI_Page_Persist.ts | 6 + resources/tester/helper_260_note_cad.cjs | 111 +++++++ resources/tester/test_260_note_cad.py | 157 ++++++++++ 10 files changed, 608 insertions(+), 32 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Cad.ts create mode 100644 resources/tester/helper_260_note_cad.cjs create mode 100644 resources/tester/test_260_note_cad.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 0399b7ad2..48ef93fe1 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -110,6 +110,8 @@ export const STATE_REGISTRY = { /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ "structure-notes": { bucket: "draft", scope: "route" }, + /** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */ + "structure-note-cads": { bucket: "draft", scope: "route" }, /** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */ "detail-table": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7c09a92ed..20acd01ee 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -474,6 +474,8 @@ export interface CrossDesign { pavement_suggested?: boolean; /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — CAD 저장 꼴 · 있으면 B07 이 글 대신 이 엔티티를 표 위에 놓음. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 서버가 다시 셈). */ detail_table?: CrossDetailTable; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ @@ -625,6 +627,8 @@ export interface CrossSectionPatch { cut_rock_area_m2?: number; /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ structure_note?: string; + /** 표 위 CAD 칸(260-1) — 세션 초안 그대로. */ + structure_note_cad?: CrossNoteCad; /** [상세] 표 손질(179-4) — 세션 초안 그대로. */ detail_table?: CrossDetailTable; } @@ -649,3 +653,9 @@ export interface CrossDetailTable { rows: Array<{ id: string; after: string | null; grouped: boolean; labels: string[] }>; columns: Array<{ id: string; label: string }>; } + +/** [상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(`cad_host` · 엔티티 · 층) 그대로. */ +export interface CrossNoteCad { + entities: Record[]; + layers?: Record[]; +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 02bfa8a79..987e4cfdc 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -94,6 +94,8 @@ export const USER_TOUCHED_KEYS = [ "cut_slope_ratio_user", // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", // [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", // 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김 diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index adab8a3c5..294045754 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -155,6 +155,8 @@ USER_TOUCHED_KEYS = ( "cut_slope_ratio_user", # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. "structure_note", + # 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 계산 입력이 아님. + "structure_note_cad", # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열(값은 그릴 때 서버가 다시 셈) "detail_table", # 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index fae03161c..039df4ca8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -179,6 +179,15 @@ class CrossDetailTable(BaseModel): columns: list[CrossDetailColumn] = Field(default_factory=list, max_length=4) +class CrossNoteCad(BaseModel): + """[상세] 표 위 CAD 칸(260-1) — CAD 저장 꼴(엔티티 · 층) 그대로 · B07 이 표 위에 놓음.""" + + model_config = ConfigDict(extra="allow") + + entities: list[dict[str, Any]] = Field(default_factory=list, max_length=2000) + layers: list[dict[str, Any]] | None = None + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -246,6 +255,8 @@ class CrossSectionPatch(BaseModel): ford_pipe_length_m: float | None = Field(default=None, ge=0) # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. structure_note: str | None = Field(default=None, max_length=2000) + # 표 위 CAD 칸(260-1) — 엔티티가 빈 칸 = 비움(옛 글 대신 아무것도 안 놓음) + structure_note_cad: CrossNoteCad | None = None # [상세] 표 손질(179-4) — 고른 칸 · 더한 행 · 열 · 값은 서버가 다시 셈 detail_table: CrossDetailTable | None = None diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index 260405b9b..a475ba9ed 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -4,8 +4,8 @@ * * 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 · * 칸 크기가 바뀌면 다시 그림) · - * 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 · - * B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) · + * 가운데 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 → + * [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) · * 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) · * 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`). * 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2). @@ -16,7 +16,7 @@ import "./B06_Section_UI_Style_Cross_Detail.css"; import { openModal } from "@ui/ui_template_modal"; -import { readState, writeState } from "../A00_Common/b_page_state"; +import { readState } from "../A00_Common/b_page_state"; import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; import type { CrossQuantitySheet, @@ -33,6 +33,7 @@ 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"; /** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ export interface CrossDetailHost { @@ -52,9 +53,7 @@ export function setCrossDetailHost(next: CrossDetailHost): void { host = next; } -/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ - -const noteKey = (chainageM: number): string => chainageM.toFixed(2); +/* ── 옛 구조물 글 세션 초안(측점키 0.01 m → 글 · 새 글은 CAD 칸 260) ─────────────── */ /** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ export function readStructureNotes(projectId: string, routeId: number): Map { @@ -62,26 +61,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map [Number(key), String(text)])); } -function noteOf(section: CrossSection): string { - const routeId = host?.routeId(); - if (host && routeId !== null && routeId !== undefined) { - const draft = readState>("structure-notes", host.projectId, routeId); - const text = draft?.[noteKey(section.chainage_m)]; - if (text !== undefined) return text; - } - return section.design?.structure_note ?? ""; -} - -function writeNote(section: CrossSection, text: string): void { - // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 - if (section.design) section.design.structure_note = text; - const routeId = host?.routeId(); - if (!host || routeId === null || routeId === undefined) return; - const all = readState>("structure-notes", host.projectId, routeId) ?? {}; - all[noteKey(section.chainage_m)] = text; - writeState("structure-notes", all, host.projectId, routeId); -} - /* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ function cell( @@ -277,12 +256,21 @@ export function openCrossDetail(section: CrossSection): void { dialogClass: "b06-detail", wide: true, mount(body, close) { - const note = document.createElement("textarea"); - note.className = "ui-input b06-detail__note"; - note.rows = 3; - note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘"; - note.value = noteOf(section); - note.addEventListener("input", () => writeNote(state.section, note.value)); + // 표 위 빈 CAD(260-2) — 높이 고정 · 한 번만 만듦 · 닫으면 destroy + const note = document.createElement("div"); + note.className = "b06-detail__note"; + note.style.height = "180px"; + 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"); const cad = document.createElement("div"); cad.className = "b06-detail__cad"; const tableBox = document.createElement("div"); @@ -369,6 +357,7 @@ export function openCrossDetail(section: CrossSection): void { stop: () => { resize.disconnect(); tablePick.detach(); + noteCad?.destroy(); }, }; // 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음 diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts new file mode 100644 index 000000000..6633cfba7 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -0,0 +1,286 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Cad.ts + * 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21). + * + * 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` · + * 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음 + * (`_Engine_Cad_Table.cross_note_cad_entities`). + * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수 + * 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면. + * 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. + * ========================================================================== */ + +import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +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"; + +const noteKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */ +export function readStructureNoteCads( + projectId: string, + routeId: number, +): Map { + const raw = readState>("structure-note-cads", projectId, routeId); + return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad])); +} + +function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void { + // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 + if (section.design) section.design.structure_note_cad = cad; + const all = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + const next = { ...all, [noteKey(section.chainage_m)]: cad }; + writeState("structure-note-cads", next, at.projectId, at.routeId); +} + +/** 세션 초안 → 저장분 → 없음(null). */ +function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null { + const draft = readState>( + "structure-note-cads", + at.projectId, + at.routeId, + ); + return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null; +} + +/* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */ + +const LAYER_ID = "기본"; +const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false }; +const FONT = 2.0; +const LINE = 3.0; +/** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */ +const NOTE_COLOR = "#e8edf4"; +const CULVERT_COLOR = "#ff7f7f"; + +const newId = (): string => crypto.randomUUID(); + +function textEntity( + label: string, + x: number, + y: number, + color: string, + align: "left" | "center" = "left", +): Record { + return { + id: newId(), + type: "Text", + lineColor: color, + lineWidth: 1, + layerId: LAYER_ID, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: align, + textColor: color, + fontSize: FONT, + fontFamily: "sans-serif", + }, + }, + }; +} + +/** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */ +export function legacyNoteEntities(note: string | undefined): Record[] { + const lines = (note ?? "") + .trim() + .split(/\r?\n/) + .map((line) => line.trimEnd()); + if (!lines.some(Boolean)) return []; + return lines.flatMap((line, index) => + line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [], + ); +} + +/* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */ + +const one = (value: number | null | undefined): string => + typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : ""; +const metres = (value: number | null | undefined): string => (one(value) ? `${one(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 { + const safe = intervalM > 0 ? intervalM : 1; + let number = Math.floor((chainageM + 1e-6) / safe); + let rest = chainageM - number * safe; + if (rest >= safe - 0.05) { + number += 1; + rest = 0; + } + return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`; +} + +function shape(type: string, shapeData: Record): Record { + return { + id: newId(), + type, + lineColor: CULVERT_COLOR, + lineWidth: 1, + layerId: LAYER_ID, + shapeData, + }; +} + +/** + * 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관 + * (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적). + * 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) · + * 관유출부보강. + */ +export function culvertNoteEntities( + section: CrossSection, + station: string, + options: Record = {}, +): 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) + return []; + const [cx, cy, radius] = [6.5, -9.0, 6.5]; + const inside = [ + "배수관", + `Φ${Math.round(culvert.diameter_m * 1000)}m/m`, + `L=${metres(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", { + startPoint: { x: cx, y: cy - radius }, + endPoint: { x: cx, y: cy - radius - LINE }, + }), + ...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 ?? {}; +} + +/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */ +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); + } + const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] }; + if (entities.length) writeNoteCad(section, cad, at); + return cad; +} + +/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */ + +export interface NoteCadPlace { + projectId: string; + routeId: number; + /** 미리 넣음 첫 줄(`stationNoLabel`). */ + station: string; +} + +/** + * [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 · + * 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤). + */ +export function mountNoteCad( + box: HTMLElement, + first: CrossSection, + section: () => CrossSection, + at: NoteCadPlace, +): { destroy: () => void } { + let closed = false; + const cad = createCadHost({ + title: "표 위 CAD", + onChanged: (dirty) => { + if (!dirty || closed) return; + cad + .requestSave() + .then(({ drawing }) => { + if (!closed) + writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at); + }) + .catch(() => undefined); + }, + }); + box.append(cad.element); + cad.beginLoading(); + const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`; + void firstNoteCad(first, at).then((drawing) => { + if (closed) return; + const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER]; + cad.load( + { entities: drawing.entities, layers }, + null, + false, + {}, + { + recoveryScope: scope, + hostTitle: "표 위 CAD", + bare: true, + }, + ); + }); + return { + destroy: () => { + closed = true; + cad.destroy(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0d99b5e12..db7fb66a3 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -35,6 +35,7 @@ import { readShoulderExtends, } from "./B06_Section_Cross_Design_Session"; import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; +import { readStructureNoteCads } from "./B06_Section_UI_Cross_Detail_Cad"; import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; @@ -413,6 +414,11 @@ export function collectSectionEdits(ctx: SectionPersistContext): { readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { patchFor(chainage).structure_note = note; }); + // 표 위 CAD 칸(260-1 · [상세] 모달 빈 도면) — 세션 초안 그대로(빈 엔티티 = 비움) + if (ctx.projectId && routeId !== null) + readStructureNoteCads(ctx.projectId, routeId).forEach((cad, chainage) => { + patchFor(chainage).structure_note_cad = cad; + }); // [상세] 표 손질(179-4) — 세션 초안 그대로 · 값은 서버가 그릴 때 다시 셈 if (ctx.projectId && routeId !== null) readDetailTables(ctx.projectId, routeId).forEach((table, chainage) => { diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs new file mode 100644 index 000000000..372e2a599 --- /dev/null +++ b/resources/tester/helper_260_note_cad.cjs @@ -0,0 +1,111 @@ +/* PLAN 260 단위검증 헬퍼 — [상세] 표 위 CAD 칸 · 옛 글 옮김 · 횡단배수 미리 넣음. + * `B06_Section_UI_Cross_Detail_Cad.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다(CAD · 세션 · 서버 = 흉내). + * 결과는 표준출력에 JSON 한 벌. 확장자 `.cjs` 까닭은 `helper_108_drag_box_hit.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +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 () => ({}) }, + "../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + throw new Error(`흉내 없는 모듈: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts")); + +const wall = (role, form, height, length) => ({ + role, + structure: "기슭막이", + revet_form: form, + revet_height_m: height, + revet_length_m: length, +}); +const culvert = (extra = {}) => ({ + type: "pipe", + chainage_m: 1433, + pipe_kind: null, + diameter_m: 1.2, + min_cover_m: 0.5, + inlet: wall("inlet", "돌쌓기(찰)", 2, 10), + outlet: wall("outlet", "돌쌓기(메)", 2, 10), + ...extra, +}); +const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({ + chainage_m: chainage, + station_id: "s", + samples: [], + culvert: spec, + design, +}); +const CHUTE = { chute_add_1_structure: "돌붙임", chute_add_1_bond: "찰붙임", chute_add_1_area: 10 }; + +/** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */ +const brief = (entities) => + entities.map((entity) => ({ + type: entity.type, + label: entity.shapeData.label ?? null, + align: entity.shapeData.options?.textAlign ?? null, + x: entity.shapeData.basePoint?.x ?? entity.shapeData.center?.x ?? null, + y: entity.shapeData.basePoint?.y ?? entity.shapeData.center?.y ?? null, + color: entity.lineColor, + layer: entity.layerId, + })); +const run = (sec, options) => brief(cad.culvertNoteEntities(sec, "NO.71+13", options)); + +const out = { + full: run(section(culvert()), CHUTE), + noLength: run(section(culvert(), {}), {}), + basin: run( + section( + culvert({ + inlet: { + role: "inlet", + structure: "집수정", + basin_form: "L", + basin_height_m: 1.7, + basin_length_m: 2, + }, + }), + ), + {}, + ), + chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), {}), + noSides: run( + section( + culvert({ + inlet: { role: "inlet", structure: "" }, + 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), + legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")), + legacyEmpty: cad.legacyNoteEntities(" ").length, + station: [ + cad.stationNoLabel(1433, 20), + cad.stationNoLabel(1420, 20), + cad.stationNoLabel(1439.97, 20), + ], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py new file mode 100644 index 000000000..774dbc8d8 --- /dev/null +++ b/resources/tester/test_260_note_cad.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위. + +- 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch. +- 260-2 미리 넣음: 횡단배수 주인 측점만 · 관 · 유입 · 유출 · 도수로 · 빈 구조물 · 옛 글 옮김 + (`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림). + +./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +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 import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 + +HERE = Path(__file__).resolve().parent +TEXT = { + "id": "t1", + "type": "Text", + "lineColor": "#ff7f7f", + "lineWidth": 1, + "layerId": "기본", + "shapeData": {"label": "NO.71+13", "basePoint": {"x": 15, "y": -1.5}, "options": {}}, +} +CAD = {"entities": [TEXT], "layers": [{"id": "기본", "name": "기본"}]} + +# ── 260-1 칸 ──────────────────────────────────────────────────────────── + + +def test_CAD_칸은_patch_스키마를_지나_그대로_남는다(): + dumped = CrossSectionPatch(chainage_m=40.0, structure_note_cad=CAD).model_dump() + merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} + assert merged == {"structure_note_cad": CAD} + # 빈 엔티티 = 비움 — 걸러지지 않고 실림(옛 글 대신 아무것도 안 놓음) + empty = CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": []}).model_dump() + assert empty["structure_note_cad"] == {"entities": [], "layers": None} + # 안 보냄 = 안 바꿈 + assert CrossSectionPatch(chainage_m=40.0).model_dump()["structure_note_cad"] is None + + +def test_CAD_칸은_엔티티_수_상한(): + with pytest.raises(ValueError): + CrossSectionPatch(chainage_m=40.0, structure_note_cad={"entities": [TEXT] * 2001}) + + +def test_CAD_칸은_재계산에서도_남는다(monkeypatch): + assert "structure_note_cad" in design_mod.USER_TOUCHED_KEYS + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + assert sections[0]["design"]["structure_note_cad"] == CAD + + +def test_브라우저가_CAD_칸을_저장_patch_에_싣는다(): + read = lambda rel: (PROJECT_ROOT / rel).read_text(encoding="utf-8") # noqa: E731 + persist = read("B06_Section/B06_Section_UI_Page_Persist.ts") + assert "readStructureNoteCads(" in persist and ".structure_note_cad = cad" in persist + state = read("A00_Common/b_page_state.ts") + assert '"structure-note-cads": { bucket: "draft", scope: "route" }' in state + # 캐시 설계 → B05 [저장] 길(`crossPatchesFromCache`)도 같은 목록 + assert '"structure_note_cad",' in read("B06_Section/B06_Section_Cross_Refresh.ts") + # 모달 = 빈 CAD(캔버스만) · 글 상자 없음 + detail = read("B06_Section/B06_Section_UI_Cross_Detail.ts") + assert "textarea" not in detail and "mountNoteCad(" in detail and "noteCad?.destroy()" in detail + assert "bare: true" in read("B06_Section/B06_Section_UI_Cross_Detail_Cad.ts") + + +# ── 260-2 미리 넣음 ───────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_260_note_cad.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _labels(entities: list[dict]) -> list[str]: + return [entity["label"] for entity in entities if entity["type"] == "Text"] + + +def test_관_유입_유출_도수로_한_벌(out): + """그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 관보호공 둘 · 관유출부보강.""" + full = out["full"] + assert [entity["type"] for entity in full[:2]] == ["Circle", "Line"] + assert _labels(full) == [ + "배수관", + "Φ1200m/m", + "L=22.0m", + "NO.71+13", + "관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m", + "관보호공(유출) 기슭막이(메,H=2.0) L=10.0m", + "관유출부보강(돌붙임,찰) 10m2", + ] + # 원 안 = 가운데 맞춤 · 원 중심 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["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"])) + + +def test_유입_집수정_은_형태_높이_길이(out): + assert "관보호공(유입) 집수정(L형,H=1.7) L=2.0m" in _labels(out["basin"]) + + +def test_도수로_옵션이_없으면_세트_구조만(out): + assert _labels(out["chuteFallback"])[-1] == "관유출부보강(도수로) m2" + + +def test_보호공을_안_고르면_관과_측점만(out): + assert _labels(out["noSides"]) == ["배수관", "Φ1200m/m", "L=22.0m", "NO.71+13"] + + +@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"]) +def test_횡단배수_주인_측점이_아니면_빈값(out, case): + """구조물 없음 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · 이웃 측점 = 빈 도면.""" + assert out[case] == [] + + +def test_옛_글은_줄마다_Text_로_옮김(out): + legacy = out["legacy"] + assert _labels(legacy) == ["L형 측구", "집수정 보강"] + # 빈 줄은 자리만 — B07 옛 글 줄과 같은 높이(줄 3.0 · 가운데) + assert [e["y"] for e in legacy] == [-1.5, -7.5] and {e["x"] for e in legacy} == {0} + assert out["legacyEmpty"] == 0 + + +def test_측점_이름은_B07_꼴(out): + assert out["station"] == ["NO.71+13", "NO.71", "NO.72"]