서버 Node 가 기준벽 역할별 넓이를 design.wall_fill_overlap 에 남기고 B08 이 다단 목록 통(base 표시 칸)으로 읽어 기준벽 줄 비고에 붙임 · 호출부(Router_Material) 안 늘림 · 936be972 빈 저장: 관 9 곳 유출 벽 258.12 1.417 · 720 1.403 · 804.18 1.166 · 982.64 0.807 · 620 0.136 · 440 0.046 · 533.98 0.004 · 85.05/900 0㎡ · 이 프로젝트 기준벽은 모두 미확정(금액 밖)이라 비고 안 뜸 · 벽 칸을 적으면 「1.42㎡ × 10m ≈ 14.2㎥」 · 금액 변화 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
327 lines
15 KiB
TypeScript
327 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_Structure_Layouts.ts
|
|
* 정본(`section.design`)만 읽어 **구조물 기하 한 벌**을 내는 자리 — 화면·조작 없이 돈다.
|
|
*
|
|
* 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 같은 기하를 세 곳이 쓴다:
|
|
* ① B06 횡단 카드(사용자 조작 중) ② B07 도면 작도 ③ 서버 초기 계산(Node 진입점).
|
|
* ①은 조작 제어기를 물고 돌아야 하고, ②·③은 저장된 값만 보면 된다. ②가 갖고 있던
|
|
* 「제어기 흉내내기」를 여기로 옮겨 ③이 그대로 쓴다 — 기하를 두 벌로 만들지 않는다.
|
|
*
|
|
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
|
|
* ========================================================================== */
|
|
|
|
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
|
|
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
|
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
|
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
|
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
|
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
|
import {
|
|
computeCardCulvert,
|
|
culvertLinkFor,
|
|
tierSpanOf,
|
|
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
|
import type {
|
|
ExtraWallControl,
|
|
InletStructureControl,
|
|
RevetOffsetControl,
|
|
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
|
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
|
|
import { computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
|
|
|
|
/** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */
|
|
const CHAINAGE_TOLERANCE_M = 0.02;
|
|
|
|
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,
|
|
};
|
|
|
|
/**
|
|
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
|
|
*
|
|
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
|
|
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
|
|
*/
|
|
function pipeOwnerChainage(
|
|
section: CrossSection,
|
|
sections: readonly CrossSection[],
|
|
): number | null {
|
|
const target = section.culvert?.chainage_m;
|
|
if (typeof target !== "number") return null;
|
|
let best: number | null = null;
|
|
for (const item of sections) {
|
|
if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) {
|
|
best = item.chainage_m;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
|
|
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
|
|
const design = section.design;
|
|
if (!design) return null;
|
|
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 culvert = computeCardCulvert(
|
|
section,
|
|
section.samples,
|
|
null,
|
|
revetOffset,
|
|
inletStructure,
|
|
extraWalls,
|
|
link,
|
|
);
|
|
const box = computeBoxLayout(section, section.samples, {
|
|
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
|
|
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
|
|
});
|
|
const ford = computeFordLayout(section, section.samples, {
|
|
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
|
|
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
|
|
});
|
|
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
|
|
const own =
|
|
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
|
|
return { design, link, culvert, box, ford, own };
|
|
}
|
|
|
|
export type StoredLayouts = NonNullable<ReturnType<typeof computeStoredLayouts>>;
|
|
|
|
/** 실제로 그려지는 설계선의 트림 — B06 카드와 **같은 우선순위**로 고른다. */
|
|
export function trimOfLayouts(layouts: StoredLayouts) {
|
|
return (
|
|
layouts.culvert?.designTrim ??
|
|
layouts.ford?.designTrim ??
|
|
layouts.box?.designTrim ??
|
|
layouts.own?.designTrim
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 정본에 얹는 키 — 구조물이 선 측점에서만 나오는 값들.
|
|
*
|
|
* 면적 넷에 **관 길이**를 더했다(2026-09-08). 관 길이는 브라우저 기하가 **m 단위 올림까지**
|
|
* 끝낸 값인데 정본에 없어 **수량이 배수관 연장을 못 냈다**(B08 창 보고). 계산을 서버로
|
|
* 옮기거나 새로 짜지 않고, **이미 서버가 Node 로 돌리는 이 다리에 한 줄 더 실었다**
|
|
* (CLAUDE.md 5장 — 계산은 한 벌).
|
|
*/
|
|
export const STRUCTURE_ROW_KEYS = [
|
|
"cut_area_m2",
|
|
"fill_area_m2",
|
|
"cut_soil_area_m2",
|
|
"cut_rock_area_m2",
|
|
"pipe_length_m",
|
|
] as const;
|
|
|
|
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
|
|
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
|
|
const trim = trimOfLayouts(layouts);
|
|
const designLine = layouts.design.design_line;
|
|
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
|
|
const ground = section.samples
|
|
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
|
|
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
|
|
.sort((a, b) => a.offset - b.offset);
|
|
return {
|
|
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
|
|
ground,
|
|
trim,
|
|
};
|
|
}
|
|
|
|
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
|
|
function areaRowOf(
|
|
section: CrossSection,
|
|
sections: readonly CrossSection[],
|
|
): Record<string, number> | null {
|
|
const layouts = computeStoredLayouts(section, sections);
|
|
if (!layouts) return null;
|
|
// 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도
|
|
// 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08).
|
|
//
|
|
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
|
|
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
|
|
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
|
|
//
|
|
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
|
// 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
|
|
// (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을
|
|
// 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다.
|
|
// ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고,
|
|
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
|
|
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
|
|
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
|
|
const ownerChainage = pipeOwnerChainage(section, sections);
|
|
const pipeOwner =
|
|
!!section.culvert &&
|
|
(ownerChainage === null ||
|
|
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
|
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
|
const pipeRow: Record<string, number> | null =
|
|
typeof pipeLengthM === "number" && pipeLengthM > 0
|
|
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
|
|
: null;
|
|
const areaInput = areaInputOf(section, layouts);
|
|
if (!areaInput) return pipeRow;
|
|
const design = layouts.design;
|
|
const areas = computeStructureAreas({
|
|
...areaInput,
|
|
rockBoundaryOffsetM:
|
|
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
|
|
});
|
|
if (!areas) return pipeRow;
|
|
const round = (value: number): number => Number(value.toFixed(4));
|
|
const row: Record<string, number> = {
|
|
...(pipeRow ?? {}),
|
|
chainage_m: section.chainage_m,
|
|
cut_area_m2: round(areas.cutAreaM2),
|
|
fill_area_m2: round(areas.fillAreaM2),
|
|
};
|
|
// 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다.
|
|
if (typeof design.cut_soil_area_m2 === "number") {
|
|
row.cut_soil_area_m2 = round(areas.cutSoilAreaM2);
|
|
row.cut_rock_area_m2 = round(areas.cutRockAreaM2);
|
|
}
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다.
|
|
* 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출
|
|
* (Node 진입점)이 이 한 벌을 함께 쓴다.
|
|
*/
|
|
export function structureAreaRows(
|
|
sections: readonly CrossSection[],
|
|
): Array<Record<string, number>> {
|
|
return sections
|
|
.map((section) => areaRowOf(section, sections))
|
|
.filter((row): row is Record<string, number> => !!row);
|
|
}
|
|
|
|
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
|
|
export interface BuiltExtraWall {
|
|
key: string;
|
|
side: "outlet" | "basin";
|
|
form: string;
|
|
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
|
|
height_m: number;
|
|
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
|
|
height_set: boolean;
|
|
form_set: boolean;
|
|
before_m: number;
|
|
after_m: number;
|
|
/** 벽 몸 중 성토 폐회로 안에 든 넓이(㎡) — 구조물 면적을 안 빼서(2026-09-06 확정) 성토에도 셈.
|
|
* B08 이 「두 번 셈」 사유로 올림(2026-09-14 브레인). 면적을 못 내는 측점은 null. */
|
|
fill_overlap_m2: number | null;
|
|
}
|
|
|
|
export interface ExtraWallRow {
|
|
chainage_m: number;
|
|
extra_walls: BuiltExtraWall[];
|
|
wall_fill_overlap: Record<string, number>;
|
|
}
|
|
|
|
/**
|
|
* 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 2026-09-14).
|
|
*
|
|
* 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라
|
|
* 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움.
|
|
* `wall_fill_overlap` — 관 기준벽 역할별 벽 몸 ∩ 성토 폐회로(㎡). B08 기준벽 줄 비고.
|
|
*/
|
|
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
|
|
const rows: ExtraWallRow[] = [];
|
|
for (const section of sections) {
|
|
if (!section.culvert) continue;
|
|
const owner = pipeOwnerChainage(section, sections);
|
|
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
|
|
const layouts = computeStoredLayouts(section, sections);
|
|
const culvert = layouts?.culvert;
|
|
if (!layouts || !culvert) continue;
|
|
const areaInput = areaInputOf(section, layouts);
|
|
const walls: BuiltExtraWall[] = [];
|
|
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
|
|
["outlet", culvert.extraWalls, culvert.revetShift.extras],
|
|
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
|
|
];
|
|
for (const [side, list, applied] of built) {
|
|
list.forEach((wall, index) => {
|
|
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
|
|
const span = tierSpanOf(section, key);
|
|
walls.push({
|
|
key,
|
|
side,
|
|
form: wall.form ?? "",
|
|
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
|
|
height_set: applied[index]?.h != null,
|
|
form_set: section.design?.revet_adjust?.[key]?.m != null,
|
|
before_m: span.beforeM,
|
|
after_m: span.afterM,
|
|
fill_overlap_m2: areaInput
|
|
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
|
|
: null,
|
|
});
|
|
});
|
|
}
|
|
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
|
|
const wall_fill_overlap: Record<string, number> = {};
|
|
for (const wall of culvert.walls) {
|
|
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
|
|
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
|
|
}
|
|
}
|
|
rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap });
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
|
|
export function applyStructureAreaRows(
|
|
sections: readonly CrossSection[],
|
|
rows: ReadonlyArray<Record<string, number>>,
|
|
): void {
|
|
for (const row of rows) {
|
|
const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as
|
|
Record<string, unknown> | undefined;
|
|
if (!design) continue;
|
|
for (const key of STRUCTURE_ROW_KEYS) {
|
|
if (typeof row[key] === "number") design[key] = row[key];
|
|
}
|
|
}
|
|
}
|