Files
Aislo/B06_Section/B06_Section_UI_Cross_Underdrain.ts
T
eomsangdonandClaude Opus 5.5 789922cedb fix(B06): 356-1 양측절토 맹암거 — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음
- reach 너머 설계선 점 거름 · 꼬리가 reach 에서 수직으로 옵셋선까지 닫음
- 시험 test_288 · helper_288 에 양측절토 + reach 형상 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 20:30:02 +09:00

165 lines
7.6 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_Underdrain.ts
* 맹암거 횡단 형상(288-1 · 요구 183-3).
*
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
* · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1).
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
* 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`).
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { cell, courses, type BandFrame } from "./B06_Section_UI_Cross_Wall_Hatch";
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
const SVG_NS = "http://www.w3.org/2000/svg";
let clipSeq = 0;
/** 돌 한 칸(m · 보기용) · 이웃과 겹침 배수 · 번갈아 기울기(도). */
const STONE_M = 0.25;
const STONE_OVERLAP = 1.4;
const STONE_TILT_DEG = 22;
/** 엇갈려 겹친 돌 무늬(306-1) — 켜(가로줄)마다 반 칸 밀고 · 돌은 칸보다 커서 이웃 · 위아래와 겹침 ·
* 기울기를 번갈아 서로 교차. `frame` v = −0.5(노면) ~ 0.5(옵셋선). */
export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM: number): void {
const rows = courses(heightM, STONE_M);
const count = courses(frame.spanM, STONE_M);
const across = (heightM / rows) * STONE_OVERLAP;
const along = (frame.spanM / count) * STONE_OVERLAP;
for (let r = 0; r < rows; r += 1) {
const shift = r % 2 ? 0 : 0.5;
for (let i = 0; i + shift <= count; i += 1) {
const tilt = (i + r) % 2 ? STONE_TILT_DEG : -STONE_TILT_DEG;
const lean = { ...frame, leanDegrees: frame.leanDegrees + tilt };
group.append(cell(lean, (i + shift) / count, (r + 0.5) / rows - 0.5, across, along, 0.45));
}
}
}
type Edge = { offset_m: number; elevation_m: number };
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
function sidePath(
edge: Edge,
outward: 1 | -1,
line: readonly Edge[],
roadGrade: number,
heightM: number,
): OffsetPoint[] {
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM;
const outside = line
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
const first = outside[0];
if (!first) return [start, { offset: start.offset, elevation: offZ(start.offset) }];
if (first.elevation_m >= edge.elevation_m) {
// 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남
const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m);
const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade);
const offset = edge.offset_m + d;
return [start, { offset, elevation: offZ(offset) }];
}
// 성토 — 사면을 따라 내려가다 옵셋선을 만나는 자리
const path: OffsetPoint[] = [start];
let prev = edge;
for (const point of outside) {
const fa = prev.elevation_m - offZ(prev.offset_m);
const fb = point.elevation_m - offZ(point.offset_m);
if (fb <= 0) {
const t = fa / (fa - fb);
const offset = prev.offset_m + (point.offset_m - prev.offset_m) * t;
path.push({ offset, elevation: offZ(offset) });
return path;
}
path.push({ offset: point.offset_m, elevation: point.elevation_m });
prev = point;
}
path.push({ offset: prev.offset_m, elevation: offZ(prev.offset_m) });
return path;
}
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
export function underdrainOutline(
design: CrossDesign,
heightM: number | null,
): OffsetPoint[] | null {
const edges = design.road_edges;
if (!edges || !(heightM && heightM > 0)) return null;
const { left, right } = edges;
const span = left.offset_m - right.offset_m;
if (!(Math.abs(span) > 1e-9)) return null;
const grade = (left.elevation_m - right.elevation_m) / span;
// 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음
const reach = design.tilt_reach;
const outward = reach?.side === "left" ? 1 : -1;
const line = (design.design_line ?? []).filter(
(p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6,
);
const leftPath = sidePath(left, 1, line, grade, heightM);
const rightPath = sidePath(right, -1, line, grade, heightM);
return [
{ offset: left.offset_m, elevation: left.elevation_m },
...rightPath,
...leftPath.slice(1).reverse(),
];
}
/** 맹암거 형상을 그린다(돌 무늬 · 둘레 클립). 그렸으면 그 무리 — 둘레에 고름 표식(`data-structure-id` · 288-2). */
export function appendUnderdrainOverlay(
svg: SVGElement,
spec: UnderdrainSpec | undefined,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
): SVGGElement | null {
const outline = spec ? underdrainOutline(design, spec.height_m) : null;
if (!spec || !outline) return null;
const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)];
const points = outline.map((p) => px(p).join(",")).join(" ");
const group = document.createElementNS(SVG_NS, "g") as SVGGElement;
group.setAttribute("class", "b06-chart__underdrain");
const body = document.createElementNS(SVG_NS, "polygon");
body.setAttribute("points", points);
body.setAttribute("class", "b06-chart__underdrain-body");
if (spec.structure_id) body.setAttribute("data-structure-id", spec.structure_id);
clipSeq += 1;
const clipId = `b06-underdrain-clip-${clipSeq}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
const clipShape = document.createElementNS(SVG_NS, "polygon");
clipShape.setAttribute("points", points);
clip.append(clipShape);
const stones = document.createElementNS(SVG_NS, "g");
stones.setAttribute("clip-path", `url(#${clipId})`);
const height = spec.height_m!;
const offsets = outline.map((p) => p.offset);
const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)];
const { left, right } = design.road_edges;
const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m);
const at = (u: number, v: number): OffsetPoint => {
const offset = lo + (hi - lo) * u;
return { offset, elevation: roadZ(offset) - height * (v + 0.5) };
};
const [ax, ay] = px(at(0, 0));
const [bx, by] = px(at(1, 0));
drawCrossedStones(
stones,
{
spanM: Math.hypot(hi - lo, grade * (hi - lo)),
pixelsPerMeter: Math.abs(x(1) - x(0)) || 1,
point: (u, v) => px(at(u, v)),
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
},
height,
);
group.append(body, clip, stones);
svg.append(group);
return group;
}