- `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
258 lines
13 KiB
TypeScript
258 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|