feat(b06): 상세 모달 표 위 빈 CAD 칸 · 횡단배수 미리 넣음 (260-1 · 260-2)

- 새 칸 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
2026-10-11 14:07:38 +09:00
co-authored by Claude Opus 5.5
parent c172c4a462
commit 29803ada06
10 changed files with 608 additions and 32 deletions
+2
View File
@@ -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 [저장]이 내보낸다. */
+10
View File
@@ -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<string, unknown>[];
layers?: Record<string, unknown>[];
}
+2
View File
@@ -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) — 기하 입력이라 재계산에도 넘김
+2
View File
@@ -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`)
+11
View File
@@ -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
+21 -32
View File
@@ -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<number, string> {
@@ -62,26 +61,6 @@ export function readStructureNotes(projectId: string, routeId: number): Map<numb
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
}
function noteOf(section: CrossSection): string {
const routeId = host?.routeId();
if (host && routeId !== null && routeId !== undefined) {
const draft = readState<Record<string, string>>("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<Record<string, string>>("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<HTMLElement>(".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) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
@@ -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<number, CrossNoteCad> {
const raw = readState<Record<string, CrossNoteCad>>("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<Record<string, CrossNoteCad>>(
"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<Record<string, CrossNoteCad>>(
"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<string, unknown> {
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<string, unknown>[] {
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<string, string> = { 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<string, unknown>, 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<string, unknown>): Record<string, unknown> {
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<string, unknown> = {},
): Record<string, unknown>[] {
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<Record<string, unknown>> {
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<CrossNoteCad> {
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<CadHostDrawing>({
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();
},
};
}
@@ -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) => {
+111
View File
@@ -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));
+157
View File
@@ -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"]