Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts
T
eomsangdonandClaude Opus 5.5 7b5ee62a42 fix(b06,b07): 표 위 CAD 배수관 원형 마크 — 아래 수직 지시선 없앰 (274-1)
- B06 상세 모달 표 위 CAD 미리 넣음(pipeMark)과 B07 짝(_pipe_mark) 모두 원 + 원 안 세 줄만
- 요구 169 원문 기록 · 시험 260 · 265 기대값 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:56:17 +09:00

355 lines
14 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Detail_Cad.ts
* 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1).
*
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
* (`_Engine_Cad_Table.cross_note_cad_entities`).
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
* 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
* ========================================================================== */
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 {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
import {
readGroupItems,
readSummaryItem,
summaryLabel,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch";
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
/** [저장]이 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)] : [],
);
}
/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
const wholeMetres = (value: number | null | undefined): string =>
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
/** 측점 이름(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)}`;
}
/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */
export interface NoteThing {
name: string;
/** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */
pipe: boolean;
/** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */
at: number;
summary_item?: unknown;
group_items?: unknown;
/** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */
optionLines?: string[];
}
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
const PIPE_BODY_LINK = "grv6lny";
/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */
export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] =>
things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M);
/**
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1).
*/
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
return thingsAt(things, chainageM).flatMap((thing) => {
const own = readSummaryItem(thing.summary_item);
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
if (own) slots.unshift(["", own]);
const names = slots
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
.map(([, item]) => summaryLabel(item));
const combos = slots.length
? [...new Set(names)]
: thing.pipe
? []
: [`${thing.name} 조합 미지정`];
return [...combos, ...(thing.optionLines ?? [])];
});
}
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
return {
id: newId(),
type,
lineColor: CULVERT_COLOR,
lineWidth: 1,
layerId: LAYER_ID,
shapeData,
};
}
/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) ·
* 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) · 지시선 없음(169). */
function pipeMark(section: CrossSection): 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=${wholeMetres(section.design?.pipe_length_m)}`,
];
return [
shape("Circle", { center: { x: cx, y: cy }, radius }), // 아래 수직 지시선 없음(169)
...inside.map((line, index) =>
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
),
];
}
/**
* 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) ·
* 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록.
*/
export function noteEntities(
section: CrossSection,
station: string,
lines: readonly string[],
): Record<string, unknown>[] {
const mark = pipeMark(section);
if (!mark.length && !lines.length) return [];
const x = mark.length ? 15 : 0;
return [
...mark,
...[station, ...lines].map((line, index) =>
textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR),
),
];
}
/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) ·
* 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */
export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
try {
const [pipes, structures, types] = await Promise.all([
readPendingPipes(projectId) ??
fetchDetailPipePoints(projectId).then((res) => res.pipe_points),
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
fetchStructureTypes(true),
]);
const byId = new Map(types.map((type) => [type.type_id, type]));
const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind;
const optionLines = (type_id: string, options?: Record<string, string | number>): string[] =>
structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id));
const piped = pipes.map((point): NoteThing => {
const kind = point.facility ?? "pipe";
const { summary_item, group_items } = point;
return {
name: nameOf(kind),
pipe: kind === "pipe",
at: point.chainage_m,
summary_item,
group_items,
optionLines: optionLines(kind, point.options as Record<string, string | number>),
};
});
const placed = structures.flatMap((item): NoteThing[] => {
const at = item.chainage_m ?? item.start_m;
if (typeof at !== "number" || !Number.isFinite(at)) return [];
const { summary_item, group_items } = item;
return [
{
name: nameOf(item.type_id),
pipe: false,
at,
summary_item,
group_items,
optionLines: optionLines(item.type_id, item.options),
},
];
});
return [...piped, ...placed];
} catch {
return [];
}
}
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면.
* 옮기거나 넣었으면 초안에 씀. */
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) {
const lines = structureNoteLines(await at.things, section.chainage_m);
entities = noteEntities(section, at.station, lines);
}
const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] };
if (entities.length) writeNoteCad(section, cad, at);
return cad;
}
/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */
export interface NoteCadPlace {
projectId: string;
routeId: number;
/** 미리 넣음 첫 줄(`stationNoLabel`). */
station: string;
/** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */
things: Promise<NoteThing[]>;
}
/**
* [상세] 모달 아래 칸(`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();
},
};
}