Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts
eomsangdon 1f3b30e698 @
feat(B06): 구조물 면적·유토곡선 서버 계산 자리 마련 + 상단측 저장 누락 수정

계산 자리 일원화(CLAUDE.md 5장) — 브라우저에서만 돌던 두 계산을 서버가 같은 TS 로
한 번 더 돌려 정본에 얹음. 파이썬 포팅 금지(기하가 두 벌이 되면 그림과 수량이 갈림).

- B06_Section_Server_Calc_Node.ts 신설 — 구조물 폐회로 면적 계산 후 그 위에서
  유토곡선을 쌓음(화면과 같은 순서). balloon 위치는 서버가 만들지 않음.
- B06_Section_Structure_Layouts.ts 신설 — 정본만 읽는 제어기 흉내를 B07 도면에서
  떼어 공용화. B07·서버가 같은 한 벌을 씀.
- common_util_node_bundle.py 신설 — 번들 빌드·실행 배관 공용화(코리도도 이걸 씀).
- 전처리 체인(초기값 스냅샷 앞)·[저장]·[확정]에서 서버 재계산 호출.
- 상단측(측구 방향) 변경이 B05 [임시저장]에만 실리던 것을 B06 [저장]·[확정]에도
  실음 — flushUphillOverrides.
- 죽은 세션 등록 항목 pipes 제거(읽는 곳도 쓰는 곳도 없었음).

검증: tsc --noEmit 통과, pytest 386 passed, tmp/tests/test_b06_server_calc_node.mjs 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-06 14:20:54 +09:00

541 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import {
computeStoredLayouts as computeLayouts,
type StoredLayouts,
} from "../B06_Section/B06_Section_Structure_Layouts";
import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box";
import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford";
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
} from "../B06_Section/B06_Section_UI_Cross_Design";
import { appendRevetmentOverlay } 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;
/** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */
frame?: number[];
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
const STRUCTURE_LAYER_ID = "b08-structure";
const STRUCTURE_COLOR = "#f6d55c";
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
const DESIGN_LAYER_ID = "b08-design";
const DESIGN_COLOR = "#b794f6";
/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */
const LABEL_FONT_MM = 2.0;
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
const CHAINAGE_TOLERANCE_M = 0.02;
/** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */
const SKIP_SELECTOR = "defs, clipPath, pattern";
/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */
const CIRCLE_SEGMENTS = 36;
type Entity = Record<string, unknown>;
type XY = [number, number];
/** 수확한 도형을 어느 도면층·색으로 넣을지. */
interface Style {
layerId: string;
color: string;
}
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
return {
id: crypto.randomUUID(),
type,
lineColor: style.color,
lineWidth: 1,
layerId: style.layerId,
shapeData,
};
}
function lineEntity(style: Style, start: XY, end: XY): Entity {
return baseEntity(style, "Line", {
startPoint: { x: start[0], y: start[1] },
endPoint: { x: end[0], y: end[1] },
});
}
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
function polyEntity(style: Style, 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(style, points[index], points[index + 1]));
}
const poly = baseEntity(style, "PolyLine", null);
poly.children = children;
return poly;
}
function textEntity(style: Style, label: string, at: XY, align: string): Entity {
return baseEntity(style, "Text", {
label,
basePoint: { x: at[0], y: at[1] },
options: {
textDirection: { x: 1, y: 0 },
textAlign: align,
textColor: style.color,
fontSize: LABEL_FONT_MM,
fontFamily: "sans-serif",
},
});
}
/**
* 공선점 제거(RamerDouglasPeucker) — 설계선은 지반 샘플마다 점을 갖고 있어(측점 하나에
* 83점) 곧은 사면이 수십 조각으로 쪼개진다. CAD에서 한 변은 한 선이어야 잡고 고칠 수 있다.
* 허용 오차는 **종이 mm**라, 1/100에서 0.05mm = 실거리 5mm — 도면상 같은 직선이다.
*/
function simplify(points: XY[], tolerance = 0.05): XY[] {
if (points.length < 3) return points;
const first = points[0];
const last = points[points.length - 1];
const [dx, dy] = [last[0] - first[0], last[1] - first[1]];
const span = Math.hypot(dx, dy);
let worst = 0;
let index = 0;
for (let i = 1; i < points.length - 1; i += 1) {
const [px, py] = points[i];
const distance =
span > 1e-9
? Math.abs(dy * px - dx * py + last[0] * first[1] - last[1] * first[0]) / span
: Math.hypot(px - first[0], py - first[1]);
if (distance > worst) {
worst = distance;
index = i;
}
}
if (worst <= tolerance) return [first, last];
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
/**
* 끝점이 맞물리는 선분들을 하나의 점열로 잇는다.
*
* B06 설계선 그리기는 **점 사이마다 `<line>` 하나**를 만든다(_Cross_Design 603행) —
* 그대로 옮기면 곧은 사면 한 변이 CAD에서 선 열댓 개가 된다. 이어 붙인 뒤 공선점을
* 지우면 한 변이 한 선이 된다(2026-08-30 사용자 지적).
*/
function chain(segments: XY[][], tolerance = 1e-3): XY[][] {
const used = new Array(segments.length).fill(false);
const near = (a: XY, b: XY): boolean =>
Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance;
const chains: XY[][] = [];
for (let seed = 0; seed < segments.length; seed += 1) {
if (used[seed]) continue;
used[seed] = true;
const points: XY[] = [segments[seed][0], segments[seed][1]];
let grew = true;
while (grew) {
grew = false;
for (let i = 0; i < segments.length; i += 1) {
if (used[i]) continue;
const [a, b] = segments[i];
const head = points[0];
const tail = points[points.length - 1];
if (near(tail, a)) points.push(b);
else if (near(tail, b)) points.push(a);
else if (near(head, b)) points.unshift(a);
else if (near(head, a)) points.unshift(b);
else continue;
used[i] = true;
grew = true;
}
}
chains.push(points);
}
return chains;
}
/** 점열을 종이 x범위로 자른다 — 경계는 선형보간으로 새 점을 만든다(서버 _clip_polyline과 같은 규칙). */
function clipX(points: XY[], x0: number, x1: number): XY[] {
const clipped: XY[] = [];
for (let index = 0; index < points.length; index += 1) {
const [x, y] = points[index];
if (index > 0) {
const [px, py] = points[index - 1];
for (const edge of [x0, x1]) {
if ((px < edge && edge < x) || (x < edge && edge < px)) {
const ratio = (edge - px) / (x - px);
clipped.push([edge, py + (y - py) * ratio]);
}
}
}
if (x >= x0 && x <= x1) clipped.push([x, y]);
}
return clipped;
}
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;
}
/**
* 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null.
*
* 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다.
* 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도
* 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정).
*/
function clipPolygonOf(element: SVGElement): XY[] | null {
const group = element.closest("g[clip-path]");
const reference = group?.getAttribute("clip-path") ?? "";
const id = reference.match(/url\(#([^)]+)\)/)?.[1];
if (!id) return null;
const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon");
if (!shape) return null;
const points = parsePoints(shape as unknown as SVGElement);
return points.length >= 3 ? points : null;
}
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
function pointInPolygon(point: XY, polygon: XY[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const straddles = yi > point[1] !== yj > point[1];
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */
function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] {
const [x0, y0] = start;
const [x1, y1] = end;
const dx = x1 - x0;
const dy = y1 - y0;
const cuts = [0, 1];
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [ex, ey] = polygon[j];
const [fx, fy] = polygon[i];
const denominator = dx * (fy - ey) - dy * (fx - ex);
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
}
cuts.sort((a, b) => a - b);
const runs: XY[][] = [];
for (let index = 0; index + 1 < cuts.length; index += 1) {
const from = cuts[index];
const to = cuts[index + 1];
if (to - from < 1e-9) continue;
const mid = (from + to) / 2;
if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue;
runs.push([
[x0 + dx * from, y0 + dy * from],
[x0 + dx * to, y0 + dy * to],
]);
}
return runs;
}
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
const runs: XY[][] = [];
for (let index = 0; index + 1 < points.length; index += 1) {
runs.push(...clipSegment(points[index], points[index + 1], polygon));
}
return runs;
}
function circlePoints(center: XY, radius: number): XY[] {
const points: XY[] = [];
for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) {
const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS;
points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]);
}
return points;
}
/**
* `rect` → 닫힌 네 모서리 점열(회전 transform 적용).
*
* 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에
* `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭
* 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은
* 무시한다 — 도면에서는 각진 칸으로 충분하다.
*/
function rectPoints(element: SVGElement): XY[] {
const x = attr(element, "x");
const y = attr(element, "y");
const width = attr(element, "width");
const height = attr(element, "height");
const corners: XY[] = [
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
[x, y],
];
const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec(
element.getAttribute("transform") ?? "",
);
if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy));
const angle = (Number(rotate[1]) * Math.PI) / 180;
const [ox, oy] = [Number(rotate[2]), Number(rotate[3])];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return corners.map(([cx, cy]) => {
const dx = cx - ox;
const dy = cy - oy;
return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos);
});
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
const entities: Entity[] = [];
const segments: XY[][] = [];
const inside = (px: number): boolean => px >= fx0 && px <= fx1;
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, rect, text");
for (const element of Array.from(nodes)) {
if (element.closest(SKIP_SELECTOR)) continue;
const tag = element.tagName.toLowerCase();
// 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다.
const clip = clipPolygonOf(element);
if (tag === "polygon" || tag === "polyline" || tag === "rect") {
const points = tag === "rect" ? rectPoints(element) : parsePoints(element);
if (tag === "polygon" && points.length > 2) points.push(points[0]);
const runs = clip ? clipPointsToPolygon(points, clip) : [points];
for (const run of runs) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));
for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) {
const cut = clipX(run, fx0, fx1);
if (cut.length === 2) segments.push(cut);
}
} else if (tag === "circle") {
const center = flip(attr(element, "cx"), attr(element, "cy"));
if (!inside(center[0])) continue;
const radius = attr(element, "r");
if (!clip) {
entities.push(
baseEntity(style, "Circle", {
center: { x: center[0], y: center[1] },
radius,
}),
);
continue;
}
// 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다.
for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "text") {
const label = (element.textContent ?? "").trim();
const at = flip(attr(element, "x"), attr(element, "y"));
if (!label || !inside(at[0])) continue;
if (clip && !pointInPolygon(at, clip)) continue;
const anchor = element.getAttribute("text-anchor");
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
entities.push(textEntity(style, label, at, align));
}
}
// 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다.
for (const points of chain(segments)) {
const poly = polyEntity(style, simplify(points));
if (poly) entities.push(poly);
}
return entities;
}
/** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */
function entityBox(entity: Record<string, unknown>): number[] | null {
const xs: number[] = [];
const ys: number[] = [];
const walk = (node: Record<string, unknown>): void => {
const shape = node.shapeData as Record<string, Record<string, number>> | null;
for (const key of ["startPoint", "endPoint", "basePoint", "center"]) {
const point = shape?.[key];
if (point) {
xs.push(point.x);
ys.push(point.y);
}
}
for (const child of (node.children as Record<string, unknown>[]) ?? []) walk(child);
};
walk(entity);
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
}
type Layouts = StoredLayouts;
/**
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
* 우선순위로 넘긴다 — 정본에는 트림 전 원본만 있어서, 이걸 안 태우면 벽이 서 있어도
* 설계선이 원래대로 지나간다(2026-08-30 사용자 지적).
*/
function drawDesign(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y);
appendCrossDesignOverlay(
svg,
design,
x,
y,
section.samples,
culvert?.designTrim ?? ford?.designTrim ?? box?.designTrim ?? own?.designTrim,
);
}
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */
function drawStructures(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { link, culvert, box, ford, own } = layouts;
if (own) appendRevetmentOverlay(svg, own, x, y);
if (box) appendBoxOverlay(svg, box, x, y);
if (ford) appendFordOverlay(svg, ford, x, y);
if (culvert) {
const linked = !section.culvert && !!link;
appendCulvertOverlay(
svg,
culvert,
x,
y,
undefined,
linked || culvert.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 {
// B05·B06과 같은 공유 캐시를 쓴다 — 도면마다 새로 받으면 장을 넘길 때마다
// 종횡단 상세를 다시 내려받아 0.2초씩 더 걸린다(2026-08-30 실측).
sections = (await loadSectionDetail(projectId, routeId)).cross_sections;
} catch {
return 0;
}
let added = 0;
const fresh: Entity[] = [];
const replaced: number[][] = [];
for (const placement of placements) {
const section = sections.find(
(item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M,
);
const frame = placement.frame;
if (!section || !frame) 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);
let entities: Entity[];
try {
const layouts = computeLayouts(section, sections);
if (!layouts) continue;
const designSvg = document.createElementNS(SVG_NS, "svg");
drawDesign(designSvg, section, layouts, x, y);
const structureSvg = document.createElementNS(SVG_NS, "svg");
drawStructures(structureSvg, section, layouts, x, y);
entities = [
...harvest(designSvg, DESIGN_STYLE, frame),
...harvest(structureSvg, STRUCTURE_STYLE, frame),
];
} catch {
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
}
if (!entities.length) continue;
// 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다.
replaced.push(frame);
fresh.push(...entities);
added += entities.length;
}
if (replaced.length) {
// 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다.
drawing.entities = drawing.entities.filter((entity) => {
if (entity.layerId !== DESIGN_LAYER_ID) return true;
const box = entityBox(entity);
if (!box) return true;
return !replaced.some(
([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1,
);
});
}
drawing.entities.push(...fresh);
return added;
}