Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts
T
eomsangdonandClaude Opus 5 8b284459c6 feat(B07): 횡단도에 구조물을 그리고, 장을 빈틈없이 채운다
구조물(배수관·기슭막이·세월교·BOX암거·물넘이포장)이 횡단도에서 빠져 있었다.
기하 산식이 전부 B06 화면(TS)에 있어 서버가 도형을 만들 수 없었기 때문이다.
파이썬으로 옮겨 적는 대신, 화면에 붙이지 않은 SVG에 B06 그리기 함수를 그대로
불러 그린 뒤 그 도형을 CAD 엔티티로 옮긴다. 산식은 한 벌로 남고, 앞으로 B06에
붙는 구조물·치수·글자는 손대지 않아도 도면에 따라온다.

자리를 맞추려면 실좌표(m)를 종이(mm)로 옮긴 값이 필요하다 — 서버가 측점별
cross_placements로 실어 보낸다.

장 배치는 전체 블록의 최대치로 칸을 통일하던 것을 버리고, 블록 크기를 먼저 재서
가장 많이 담기는 행 수를 고른다. 열폭은 그 열의 최대폭, 행높이는 그 행의 최대높이라
행·열은 그대로 맞는다. 같은 노선에서 4장이 3장이 됐다(12/6/5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 16:49:57 +09:00

306 lines
11 KiB
TypeScript

/* =============================================================================
* B07_DesignDetail_UI_Cad_Structures.ts
* 횡단도 **구조물 작도** — 배수관·기슭막이·세월교·BOX암거·물넘이포장.
*
* 산식은 새로 만들지 않는다. B06 화면이 쓰는 기하·그리기 함수를 **그대로** 불러
* 화면에 붙이지 않은 오프스크린 SVG에 그린 뒤, 그 도형을 CAD 엔티티로 옮긴다
* (2026-08-30 사용자 확정: 프론트에서 B06 산식 재사용 — 파이썬 포팅 금지).
* 앞으로 B06에 구조물·치수·글자가 붙으면 여기 손대지 않아도 도면에 따라온다.
*
* 자리 맞추기는 서버가 도면에 실어 보내는 `cross_placements`가 정한다:
* x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy
* SVG는 y가 아래로 자라므로 그릴 때 부호를 뒤집고 수확할 때 되돌린다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { fetchSectionDetail } from "../B06_Section/B06_Section_Api_Fetch";
import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box";
import {
computeBoxLayout,
DEFAULT_BOX_SIDE_ADJUST,
} from "../B06_Section/B06_Section_UI_Cross_Box_Geom";
import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import {
DEFAULT_BASIN_ADJUST,
ZERO_ADJUST,
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import {
computeCardCulvert,
culvertLinkFor,
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford";
import {
computeFordLayout,
DEFAULT_FORD_WALL_ADJUST,
} from "../B06_Section/B06_Section_UI_Cross_Ford_Geom";
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import {
appendRevetmentOverlay,
computeRevetmentLayout,
} from "../B06_Section/B06_Section_UI_Cross_Revetment";
/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */
export interface CrossPlacement {
chainage_m: number;
ox: number;
oy: number;
dy: number;
mm_per_m: number;
x0: number;
x1: number;
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
const STRUCTURE_LAYER_ID = "b08-structure";
const STRUCTURE_COLOR = "#f6d55c";
/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */
const LABEL_FONT_MM = 2.0;
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
const CHAINAGE_TOLERANCE_M = 0.02;
/** 정의부(해칭 패턴·클립)는 도형이 아니다. 클립된 해칭은 1차 제외(잘라 낼 수단이 없다). */
const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]";
type Entity = Record<string, unknown>;
type XY = [number, number];
function baseEntity(type: string, shapeData: unknown): Entity {
return {
id: crypto.randomUUID(),
type,
lineColor: STRUCTURE_COLOR,
lineWidth: 1,
layerId: STRUCTURE_LAYER_ID,
shapeData,
};
}
function lineEntity(start: XY, end: XY): Entity {
return baseEntity("Line", {
startPoint: { x: start[0], y: start[1] },
endPoint: { x: end[0], y: end[1] },
});
}
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
function polyEntity(points: XY[]): Entity | null {
if (points.length < 2) return null;
const children: Entity[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
children.push(lineEntity(points[index], points[index + 1]));
}
const poly = baseEntity("PolyLine", null);
poly.children = children;
return poly;
}
function textEntity(label: string, at: XY, align: string): Entity {
return baseEntity("Text", {
label,
basePoint: { x: at[0], y: at[1] },
options: {
textDirection: { x: 1, y: 0 },
textAlign: align,
textColor: STRUCTURE_COLOR,
fontSize: LABEL_FONT_MM,
fontFamily: "sans-serif",
},
});
}
function attr(element: SVGElement, name: string): number {
return Number(element.getAttribute(name) ?? 0);
}
/** SVG 좌표(y 아래로 증가) → 도면 좌표(y 위로 증가). */
function flip(x: number, y: number): XY {
return [x, -y];
}
function parsePoints(element: SVGElement): XY[] {
const raw = (element.getAttribute("points") ?? "").trim();
if (!raw) return [];
const numbers = raw.split(/[\s,]+/).map(Number);
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push(flip(numbers[index], numbers[index + 1]));
}
return points;
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다. */
function harvest(root: SVGElement): Entity[] {
const entities: Entity[] = [];
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, text");
for (const element of Array.from(nodes)) {
if (element.closest(SKIP_SELECTOR)) continue;
const tag = element.tagName.toLowerCase();
if (tag === "polygon" || tag === "polyline") {
const points = parsePoints(element);
if (tag === "polygon" && points.length > 2) points.push(points[0]);
const poly = polyEntity(points);
if (poly) entities.push(poly);
} else if (tag === "line") {
entities.push(
lineEntity(
flip(attr(element, "x1"), attr(element, "y1")),
flip(attr(element, "x2"), attr(element, "y2")),
),
);
} else if (tag === "circle") {
const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy"));
entities.push(baseEntity("Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }));
} else if (tag === "text") {
const label = (element.textContent ?? "").trim();
if (!label) continue;
const anchor = element.getAttribute("text-anchor");
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
entities.push(textEntity(label, flip(attr(element, "x"), attr(element, "y")), align));
}
}
return entities;
}
// ---------------------------------------------------------------------------
// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다.
// ---------------------------------------------------------------------------
function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
const stored = section.design?.revet_adjust?.[role];
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...ZERO_ADJUST };
}
const revetOffset: RevetOffsetControl = {
adjustFor: (section, role) => storedWallAdjust(section, role),
storedAdjustFor: (section, role) =>
section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null,
selectedFor: () => null,
highlightFor: () => null,
select: () => undefined,
syncApplied: () => undefined,
update: () => undefined,
reset: () => undefined,
};
const extraWalls: ExtraWallControl = {
countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0,
setCount: () => undefined,
equalize: () => undefined,
consumeEqualize: () => false,
syncCount: () => undefined,
};
const inletStructure: InletStructureControl = {
valueFor: (section) => section.design?.inlet_structure ?? "auto",
adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }),
set: () => undefined,
updateAdjust: () => undefined,
resetAdjust: () => undefined,
};
/** 한 측점의 구조물을 오프스크린 SVG에 그린다. 그리는 순서는 B06 카드와 같다. */
function drawStructures(
svg: SVGElement,
section: CrossSection,
sections: CrossSection[],
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const design = section.design;
if (!design) return;
const designZAt = (chainageM: number): number | null => {
const found = sections.find(
(item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M,
);
return found?.design?.design_elevation_m ?? null;
};
const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt);
const culvertLayout = computeCardCulvert(
section,
section.samples,
null,
revetOffset,
inletStructure,
extraWalls,
link,
);
const boxLayout = computeBoxLayout(section, section.samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
});
const fordLayout = computeFordLayout(section, section.samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
});
appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
if (!section.culvert && !link) {
appendRevetmentOverlay(svg, computeRevetmentLayout(section, design.revet_adjust?.own), x, y);
}
if (boxLayout) appendBoxOverlay(svg, boxLayout, x, y);
if (fordLayout) appendFordOverlay(svg, fordLayout, x, y);
if (culvertLayout) {
const linked = !section.culvert && !!link;
appendCulvertOverlay(
svg,
culvertLayout,
x,
y,
undefined,
linked || culvertLayout.culvert.hidden_pipe === true,
);
}
}
/**
* 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면
* 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다.
*/
export async function appendStructureEntities(
projectId: string,
routeId: number,
drawing: { entities: Record<string, unknown>[]; cross_placements?: CrossPlacement[] },
): Promise<number> {
const placements = drawing.cross_placements ?? [];
if (!placements.length) return 0;
let sections: CrossSection[];
try {
sections = (await fetchSectionDetail(projectId, routeId)).cross_sections;
} catch {
return 0;
}
let added = 0;
for (const placement of placements) {
const section = sections.find(
(item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M,
);
if (!section) continue;
if (!section.culvert && !section.ford && !section.box && !section.ford_pavement) {
// 연동으로 옆에서 이어져 온 기슭막이는 세트가 없어도 그려야 하므로 정본만 더 본다.
if (!section.revetment && !sections.some((item) => item.culvert)) continue;
}
const x = (offset: number): number => offset * placement.mm_per_m + placement.ox;
const y = (elevation: number): number =>
-((elevation - placement.dy) * placement.mm_per_m + placement.oy);
const svg = document.createElementNS(SVG_NS, "svg");
try {
drawStructures(svg, section, sections, x, y);
} catch {
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
}
const entities = harvest(svg);
drawing.entities.push(...entities);
added += entities.length;
}
return added;
}