Files
Aislo/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts
T
eomsangdonandClaude Opus 5.5 cb4bab7bf2 feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침
- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:06:13 +09:00

258 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 { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { tagShape } from "../A00_Common/design/design_pick_match";
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") tagShape(el, pipeIdAt(spec.chainage_m), "concrete"); // 관 세션 id · 부분(323-5)
});
// 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_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;
}