Files
Aislo/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts
T
eomsangdonandClaude Opus 5.5 b7eb44cd5c fix(b06): 물넘이포장 도수로/돌붙임 길이 = 성토 사면보다 길면 원지반 이음 조각까지 한 선 · 월류 높이선 성토 쪽 = 성토선 점선 연장과 교점 (315)
- 315-1 fordChuteLine — 길이 맞춘 선의 첫 조각만 쓰던 흠(원지반 이음 조각 버림) · 이은 조각까지 한 선 · 길이 없음 · 짧은 길이 · 절토 쪽 그대로
- 315-2 overflowLine — 성토 쪽(노면 끝 밖 설계선이 내려감) = 성토 사면선을 노면 끝 너머 위로 연장해 월류 높이선과 만나는 자리에서 끝 · 절토 · 늘인 노면 끝(294-3) 그대로 · 같은 점선 양식(B06 카드 · B07 [상세] 같은 그림)
- 시험 test_315_ford_chute_overflow 새로 · test_287 월류선 기대값 315 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 19:46:28 +09:00

257 lines
13 KiB
TypeScript
Raw 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.
/* =============================================================================
* B06_Section_UI_Cross_Ford_Pavement.ts
* 물넘이포장 — 횡단도 그림(287-2 · 287-4 · 그림_183).
*
* · 콘크리트 윗면 = 설계 노면 — 서버(`compute_cross_design(tilted=…)`) · 짝 TS 가 노면을 경사각도
* (`ford_slope_deg` → `slope_pct`)로 지대 낮은 쪽 한 경사로 기울여 낸 면 · 설계 중심점이 그 위.
* 노면 밖은 설계선 그대로 — 노면 끝에서 성토 · 절토 사면이 이어짐(늘어나지 못한 자리 = 성토).
* · 포장층 = 노면공 포장 형상(`appendPavementOverlay`)을 노견까지.
* · 월류 높이선(회색 점선) = 노면 위로 월류 높이(`depth_m`)만큼 · 노면과 같은 각도 · 성토 쪽은 성토선을
* 점선으로 늘여 이음(315-2).
* · 도수로 · 돌붙임 띠 = 유출(지대 낮은 쪽) 성토사면.
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { appendPavementOverlay } from "./B06_Section_UI_Cross_Design_Surface";
import { appendChuteBand, chuteRun } from "./B06_Section_UI_Cross_Wall_Hatch";
/** 백엔드 `section.ford_pavement` 제원(치수 결정은 서버 몫 — 여기서는 좌표만 만든다). */
export interface FordPavementSpec {
/** 관 지점 누가거리 — 폭 절반 걸친 옆 카드도 같은 값. */
chainage_m?: number;
span_m: number;
/** 월류 높이(m) — 노면 위 점선. 없으면 점선을 안 그림(수치를 지어내지 않는다). */
depth_m: number | null;
/** 노면 경사(%) — 경사각도 환산 · 서버 설계 노면이 이 값으로 기욺. */
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
/** 도수로 · 돌붙임(`ford_chute_{n}_*` · 136-2) — 유출측 성토사면 띠 · 길이 = 사면 따라(286-3 · 비면 원지반까지). */
chute?: { structure: string; thickness_m: number; length_m?: number };
}
/** 유입(상류)이 좌측(+offset)인가 — 기울인 노면이면 높은 노면 끝 · 아니면 설계선 양 끝 표고가 높은
* 쪽(286-2) · 같으면 단면유형. */
export function inflowLeft(design: CrossDesign): boolean {
const edges = design.road_edges;
if (design.tilted && edges && Math.abs(edges.left.elevation_m - edges.right.elevation_m) > 1e-6)
return edges.left.elevation_m > edges.right.elevation_m;
const line = [...(design.design_line ?? [])].sort((a, b) => a.offset_m - b.offset_m);
const gap = line.length >= 2 ? line[line.length - 1].elevation_m - line[0].elevation_m : 0;
return Math.abs(gap) > 1e-3 ? gap > 0 : design.section_mode !== "right_cut";
}
function line(points: string[], className: string): SVGPolylineElement {
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", className);
return polyline;
}
/**
* 세월교로 **내려 앉힌 노면** 위에 "월류가 없었다면" 노면을 점선으로 되그린다
* (2026-08-30 사용자 확정). 계획고는 이미 백엔드가 월류 높이만큼 내려 잡았으므로
* (`design.surface_drop_m`) 여기서는 그 양만큼 위로 올린 선을 얹는다.
* 물넘이포장의 「기존 계획고 점선」과 같은 표기라 사용자가 한 규칙으로 읽는다.
*
* 양 끝은 **측벽 상단의 계류측 꼭짓점까지 대각으로 내려 잇는다**(2026-08-30 사용자:
* 좌측 벽이면 좌상단, 우측 벽이면 우상단, 각도는 무시). 월류 전 노면이 구체 밖에서
* 어디로 떨어지는지가 그 선으로 읽힌다.
*/
export function appendFordSurfaceDropPlan(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
ford?: {
sides: ReadonlyArray<{ outward: number; wallTopOuter: OffsetPoint }>;
ford?: { overflow_depth_m?: number };
} | null,
): void {
// 점선 = 노면 + 월류 높이(66-3) — 패널 월류 높이(세트 제원)가 먼저 · 저장 뒤 내린 양과 같은 값
const drop = ford?.ford?.overflow_depth_m ?? design.surface_drop_m ?? 0;
if (!(drop > 0) || !design.road_edges) return;
const { left, right } = design.road_edges;
// +offset이 좌측이다 — 좌측 벽 상단 → 좌 노견 → 우 노견 → 우측 벽 상단 순.
const cornerAt = (side: 1 | -1): OffsetPoint | undefined =>
ford?.sides.find((entry) => Math.sign(entry.outward) === side)?.wallTopOuter;
const points: OffsetPoint[] = [
{ offset: left.offset_m, elevation: left.elevation_m + drop },
{ offset: right.offset_m, elevation: right.elevation_m + drop },
];
const leftCorner = cornerAt(1);
if (leftCorner) points.unshift(leftCorner);
const rightCorner = cornerAt(-1);
if (rightCorner) points.push(rightCorner);
svg.append(
line(
points.map((point) => `${x(point.offset)},${y(point.elevation)}`),
"b06-chart__ford-deck-plan",
),
);
}
/**
* 물넘이포장을 그린다(287-2 · 287-4) — 포장층(노면공 형상 · 노견까지) · 월류 높이선 · 도수로 띠.
* 그렸으면 true — 호출부는 일반 포장층을 건너뜀(두 겹으로 안 깔림). 포장층에 물넘이 표식
* (`b06-chart__ford-pavement` · 287-5 고름이 노면공 포장 고름과 가름).
*/
export function appendFordPavementOverlay(
svg: SVGElement,
spec: FordPavementSpec | undefined,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
/** 원지반 — 도수로 띠가 원지반에 닿으면 끝 · 길이가 사면보다 길면 이어 그림(294-4). */
ground?: readonly OffsetPoint[],
): boolean {
const edges = fordEdges(design);
if (!spec || !edges) return false;
const from = svg.children.length;
// 노면공 항목은 안 넘김 — 물넘이 자체 포장(노면공 포장 고름과 안 섞임)
appendPavementOverlay(svg, { ...design, paved: true, carriageway_edges: edges }, x, y, null);
[...svg.children]
.slice(from)
.filter((el) => el.getAttribute("class") === "b06-chart__pavement")
.forEach((el) => {
el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement");
// 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글
if (typeof spec.chainage_m === "number")
el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`);
});
// 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183) · 성토 쪽 = 성토선 연장(315-2)
const depth = spec.depth_m ?? 0;
if (depth > 0)
svg.append(
line(
overflowLine(design, depth).map((p) => `${x(p.offset)},${y(p.elevation)}`),
"b06-chart__ford-deck-plan",
),
);
if (spec.chute)
appendChuteBand(svg, [{ points: fordChuteLine(design, spec.chute, ground) }], spec.chute, x, y);
return true;
}
/** 콘크리트 · 노면 양 끝 — 낮은 쪽을 늘였으면(294-3 `tilt_reach`) 그 쪽은 원지반과 만난 자리까지. */
export function fordEdges(design: CrossDesign): CrossDesign["road_edges"] | undefined {
const edges = design.road_edges;
const reach = design.tilt_reach;
if (!edges || !reach) return edges;
const end = { offset_m: reach.offset_m, elevation_m: reach.elevation_m };
return reach.side === "left" ? { ...edges, left: end } : { ...edges, right: end };
}
/**
* 월류 높이선(315-2) — 노면 양 끝 위 월류 높이 · 노면과 같은 각도. 성토 쪽(노면 끝 밖 설계선이 내려감)은
* 성토 사면선을 노면 끝 너머 위 · 안쪽으로 늘여 월류 높이선과 만나는 자리에서 끝 → 노면 끝까지 그 연장선.
* 절토 쪽 · 늘인 노면 끝(294-3)은 노면 끝 위 그대로. 좌(+offset) → 우 차례.
*/
export function overflowLine(design: CrossDesign, depth: number): OffsetPoint[] {
const edges = fordEdges(design);
if (!edges) return [];
const { left, right } = edges;
const span = Math.abs(left.offset_m - right.offset_m);
const side = (edge: typeof left, other: typeof left): OffsetPoint[] => {
const top = { offset: edge.offset_m, elevation: edge.elevation_m + depth };
const outward = Math.sign(edge.offset_m - other.offset_m) || 1;
const next = (design.design_line ?? [])
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward)[0];
const cut = edge.offset_m === design.tilt_reach?.offset_m; // 늘인 끝 = 원지반(294-3 · 절토)
if (cut || !next || next.elevation_m >= edge.elevation_m - 1e-6 || span < 1e-9) return [top];
// 안쪽 d m 에서 성토선 = edge + fill·d · 월류선 = edge + depth + road·d → d = depth / (fill − road)
const fill = (edge.elevation_m - next.elevation_m) / Math.abs(next.offset_m - edge.offset_m);
const road = (other.elevation_m - edge.elevation_m) / span;
if (fill <= road + 1e-9) return [top];
const d = Math.min(depth / (fill - road), span);
const meet = { offset: edge.offset_m - outward * d, elevation: edge.elevation_m + fill * d };
return [{ offset: edge.offset_m, elevation: edge.elevation_m }, meet];
};
return [...side(left, right), ...side(right, left).reverse()];
}
/** 원지반 꺾은선의 그 오프셋 표고(밖은 끝값). */
function groundZ(ground: readonly OffsetPoint[], offset: number): number | null {
if (!ground.length) return null;
if (offset <= ground[0].offset) return ground[0].elevation;
for (let i = 1; i < ground.length; i += 1) {
const [a, b] = [ground[i - 1], ground[i]];
if (offset > b.offset) continue;
const span = b.offset - a.offset;
return span > 1e-9
? a.elevation + ((b.elevation - a.elevation) * (offset - a.offset)) / span
: b.elevation;
}
return ground[ground.length - 1].elevation;
}
/**
* 유출측(낮은 쪽) 성토사면 선(136-2 · 294-4) — 낮은 쪽 끝단(늘인 노면 끝 · 아니면 유출 쪽 노견 끝)에서
* 설계선을 바깥으로 따라 내려가는 동안만(오르면 절토라 멈춤) · 원지반(`ground`)에 닿으면 끝.
* 성토가 없으면(끝단이 이미 원지반 · 양측 절토 물넘이) 점 하나 = 띠 없음.
*/
export function outletFillLine(
design: CrossDesign,
ground?: readonly OffsetPoint[],
): OffsetPoint[] {
const edges = fordEdges(design);
if (!edges || !design.design_line?.length) return [];
const reach = design.tilt_reach;
const outLeft = reach ? reach.side === "left" : !inflowLeft(design); // +offset = 좌측
const outward = outLeft ? 1 : -1;
const edge = outLeft
? Math.max(edges.left.offset_m, edges.right.offset_m)
: Math.min(edges.left.offset_m, edges.right.offset_m);
const points = design.design_line
.filter((p) => (p.offset_m - edge) * outward >= -1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
const line: OffsetPoint[] = [];
for (const p of points) {
const last = line[line.length - 1];
if (last && p.elevation_m > last.elevation + 1e-6) break;
line.push({ offset: p.offset_m, elevation: p.elevation_m });
const z = ground ? groundZ(ground, p.offset_m) : null;
if (z !== null && Math.abs(z - p.elevation_m) < 1e-3) break; // 원지반에 닿음 = 끝
}
return line;
}
/**
* 물넘이 도수로 · 돌붙임 선(286-3 · 294-4) — 낮은 쪽 끝단에서 성토사면 따라 · 길이 비움 = 원지반 닿으면 끝 ·
* 길이 넣음 = 그 길이(사면보다 길면 원지반 따라 이어 · 315-1 이은 조각까지 한 선) · 양측 절토(늘인 노면 끝이
* 원지반) = 길이 없으면 안 그림. 횡단 띠 · 3D 띠가 같이 씀(그림 전용).
*/
export function fordChuteLine(
design: CrossDesign,
chute?: { length_m?: number },
ground?: readonly OffsetPoint[],
): OffsetPoint[] {
return chuteRun([{ points: outletFillLine(design, ground) }], chute?.length_m, ground).flatMap(
(run, i) => (i ? run.points.slice(1) : run.points),
);
}
/**
* 좌측 패널의 물넘이 칸을 캐시 제원에 얹는다(66-2 · 66-3) — 월류 높이 = 파임 깊이 · 경사각(°) → %.
* 서버 `_ford_pavement_set` 과 같은 환산(저장 뒤 서버가 정본으로 다시 냄). 바꿨으면 true.
*/
export function applyFordPavementOptions(
spec: FordPavementSpec,
patch: Record<string, number | string>,
): boolean {
const num = (key: string): number | undefined => {
const value = patch[key] === "" ? NaN : Number(patch[key]);
return Number.isFinite(value) ? value : undefined;
};
const height = num("ford_height_m");
const degree = num("ford_slope_deg");
if (height !== undefined) spec.depth_m = height > 0 ? height : null;
if (degree !== undefined) {
spec.slope_pct = Number((Math.tan((degree * Math.PI) / 180) * 100).toFixed(4));
spec.defaulted = spec.defaulted?.filter((item) => !item.startsWith("경사각 "));
}
return height !== undefined || degree !== undefined;
}