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:
@@ -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 [저장]이 내보낸다. */
|
||||
|
||||
@@ -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>[];
|
||||
}
|
||||
|
||||
@@ -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) — 기하 입력이라 재계산에도 넘김
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user