feat(B05): 날개벽 탑뷰 투영 — 판 풋프린트 + 성토끝 사다리꼴 박스

날개벽 링은 노선이 아니라 날개 축을 탄다. 그래서 링 폴리곤의 편거리가 노선
편거리가 아니라 패널 두께(±0.10m)라 노견 클립 max(−0.10, +2)에 걸려 날개벽
투영 고리가 0개였다. 실측: 링 좌향과 노선 좌향 정렬 align=0.71, 링 중심의 노선
편거리 2.74~4.16(좌)·−4.01~−2.60(우) — 이미 노견 밖이다.

- `CorridorStructure.wing` 표식 신설, `_Structures_Wing.ts`가 붙인다.
- 날개벽은 도로부 클립을 생략하고 링 위치로 측을 가린다 — 판 두께 × 날개 길이
  직사각형이 나온다.
- `wing-box` 커브 신설(주황) — 밑변은 날개 시작~끝 모서리, 양 옆선은 구조물 축
  (측점 좌향 벡터)과 평행한 고정 직선, 바깥 변은 원본 성토 최하단 끝선 그대로.
  노선 곡률을 따라 각도가 바뀌지 않는다(사용자 확정).
- 성토 끝선은 세트 ±40m만 잘라 쓰고 사거리 15m로 조인다 — 안 조이면 헤어핀
  건너편 끝선에 걸려 −29m짜리 박스(점 115개)가 나온다.
- 교점 탐색은 광선이 아니라 양방향 직선이다 — 세월교 날개는 끝이 성토 끝선
  밖이라(4.16 vs 2.60) 바깥으로만 쏘면 통째로 빠진다.
- `BUILD_VERSION` 32 → 33.

실측: 판 고리가 측당 2장씩 n=11로 살아났다. 박스는 200.92 4/4, 149.73 3/4.
박스 바깥 변의 점은 전부 성토 끝선 위(이탈 0.000m). 149.73 우측 날개 1장은
그 자리에 끝선이 없어 안 그린다 — 처리 방침은 사용자 확정 대기.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:47:12 +09:00
co-authored by Claude Opus 5
parent 5c8986d46c
commit 2da4ecb038
6 changed files with 177 additions and 6 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ function fnv1a(text: string): string {
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
* 섞어 두면 배포와 동시에 저장본이 만료된다.
*/
const BUILD_VERSION = 32; // 구조물 탑뷰 풋프린트 재구현 — 부재별 표기(2026-08-26).
const BUILD_VERSION = 33; // 날개벽 판 투영 + 성토끝 박스(2026-08-26).
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -40,6 +40,8 @@ const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
structure: 0xffd93d,
"slope-deformed": 0xff2d78,
"slope-original": 0x38bdf8,
// 날개벽 성토끝 박스 — 구조물 노랑과 구분되게 주황(2026-08-26 사용자).
"wing-box": 0xff8c1a,
};
/** 유출측 파선 눈금(m) — 유입은 실선이다. */
+37 -3
View File
@@ -19,12 +19,12 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
import { assembleStripLoops } from "./B05_Profile_UI_Corridor_Carve";
import { structureFootprintLoops } from "./B05_Profile_UI_Corridor_Plan_Footprint";
import { structureFootprintLoops, wingBoxLoops } from "./B05_Profile_UI_Corridor_Plan_Footprint";
import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station";
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
import type { PXY } from "./B05_Profile_UI_Corridor_Union";
export type PlanCurveSource = "structure" | "slope-deformed" | "slope-original";
export type PlanCurveSource = "structure" | "slope-deformed" | "slope-original" | "wing-box";
/** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */
export interface PlanCurve {
@@ -93,6 +93,29 @@ function at(frame: PlanFrame, offset: number): PXY {
return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset };
}
/**
* 성토 끝선에서 세트 근처 구간만 — 노선 누가거리 ± 이만큼(m)을 본다.
*
* 넉넉해야 한다. 곡선 안쪽 측은 종방향이 압축돼 누가거리 20m를 잘라도 측점 로컬
* 종방향으로는 5.2m밖에 안 나왔고, 종방향 6.4m가 필요한 날개가 끝선을 못 만났다
* (2026-08-26 실측, 149.73 세월교 우측). 엉뚱한 자리에 걸리는 건 `RAY_LIMIT_M`이 막는다.
*/
const TOE_WINDOW_M = 40;
/** 그 측 성토 끝선(코리도 바깥 윤곽)에서 세트 근처 마디만 잘라낸다. */
function nearToe(
outline: CorridorOutline,
side: Side,
setChainageM: number,
): Array<[number, number]> {
const points: Array<[number, number]> = [];
outline.chainages.forEach((chainageM, index) => {
if (Math.abs(chainageM - setChainageM) > TOE_WINDOW_M) return;
points.push(side === "left" ? outline.left[index] : outline.right[index]);
});
return points;
}
/** 세트 구조물의 최고 표고 — 폴리곤 표고에 링 종단 보정(dz)을 더한 값의 최대. */
function topElevationOf(set: ReadonlyArray<CorridorStructure>): number | null {
let top: number | null = null;
@@ -187,8 +210,19 @@ export function buildPlanCurves(
});
};
const axis = {
cx: section.center_x,
cy: section.center_y,
leftX: section.frame.left_xy[0],
leftY: section.frame.left_xy[1],
};
(["left", "right"] as const).forEach((side) => {
push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ));
push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ, axis));
// 날개벽만 — 성토 끝선까지 이어진 사다리꼴(2026-08-26 사용자). 성토 끝선은
// 노선 전 구간 폴리라인이라 세트 근처만 잘라 넘긴다 — 안 자르면 헤어핀
// 건너편 성토선에 광선이 얻어걸린다(패치 리본이 겪은 것과 같은 함정).
const toe = nearToe(outline, side, section.chainage_m);
push("wing-box", side, wingBoxLoops(set, side, planZ, axis, toe));
});
for (const silhouette of structureSilhouettes(section)) {
@@ -79,22 +79,47 @@ function runLoop(
return loop;
}
/** 측점 프레임(중심 + 좌향 단위벡터) — 날개벽이 어느 측인지 가리는 기준이다. */
export interface StationAxis {
cx: number;
cy: number;
leftX: number;
leftY: number;
}
/** 그 자리의 노선 편거리(+ = 좌측) — 측점 프레임 기준. */
function routeOffset(axis: StationAxis, x: number, y: number): number {
return (x - axis.cx) * axis.leftX + (y - axis.cy) * axis.leftY;
}
/** 날개벽이 붙은 측 — 링 가운데의 노선 편거리 부호로 가린다. */
function wingSide(rings: ReadonlyArray<StructureFrame>, axis: StationAxis): Side {
const middle = rings[Math.floor(rings.length / 2)];
return routeOffset(axis, middle.cx, middle.cy) >= 0 ? "left" : "right";
}
/**
* 구조물 세트의 그 측 탑뷰 외곽 고리들 — **부재마다 따로**, 도로부(노견 안쪽)는 뺀다.
*
* 배관은 뺀다 — 다른 부재가 감싸고 있어 투영 면적에 안 들어간다(2026-08-26 사용자).
*
* 날개벽(`wing`)은 링이 노선이 아니라 **날개 축**을 타 링 폴리곤의 편거리가 노선
* 편거리가 아니라 **패널 두께**다. 도로부 클립을 그대로 걸면 전부 죽으므로 클립을
* 생략하고, 대신 링 위치로 측을 가린다 — 날개는 애초에 노견 밖에서 시작한다.
*/
export function structureFootprintLoops(
set: ReadonlyArray<CorridorStructure>,
side: Side,
roadEdgeOffset: number,
planZ: number,
axis: StationAxis,
): Array<Array<[number, number, number]>> {
const loops: Array<Array<[number, number, number]>> = [];
for (const structure of set) {
if (structure.kind === "pipe") continue;
const rings = structure.rings;
if (!rings || rings.length < 2) continue;
if (structure.wing && wingSide(rings, axis) !== side) continue;
let run: Row[] = [];
const flush = (): void => {
const loop = runLoop(run, side, planZ);
@@ -104,7 +129,11 @@ export function structureFootprintLoops(
rings.forEach((frame, index) => {
const polygon = structure.polygons?.[index] ?? structure.polygon;
const span = polygonSpan(polygon);
const clipped = span ? clipOutsideRoad(span, side, roadEdgeOffset) : null;
const clipped = structure.wing
? span
: span
? clipOutsideRoad(span, side, roadEdgeOffset)
: null;
if (clipped) run.push({ frame, span: clipped });
else flush();
});
@@ -112,3 +141,103 @@ export function structureFootprintLoops(
}
return loops;
}
/** 광선이 성토 끝선(폴리라인)과 만난 자리 — 교점과 그 앞 마디 번호. */
interface ToeHit {
x: number;
y: number;
segment: number;
}
/**
* 이보다 멀리서 만난 자리는 안 쓴다(m) — 헤어핀 건너편 성토선에 얻어걸리는 걸 막는다.
* 실측상 날개 끝↔성토 끝선은 1.6~2.9m다. 넉넉히 잡아도 이 정도면 충분하다.
*/
const RAY_LIMIT_M = 15;
/**
* 원점을 지나 방향 `(dx, dy)`를 타는 **직선**이 성토 끝선과 만나는 가장 가까운 자리.
*
* 광선(바깥쪽만)이 아니라 직선인 이유 — 세월교 날개는 끝이 성토 끝선 **밖으로**
* 나간다(2026-08-26 실측: 날개 끝 편거리 4.16, 그 자리 성토 끝선 2.60). 바깥으로만
* 쏘면 영영 안 만나 박스가 통째로 빠진다. 안쪽에서 만나도 "성토 끝선과 만나게"라는
* 규칙은 그대로 성립한다.
*/
function castToToe(
ox: number,
oy: number,
dx: number,
dy: number,
toe: ReadonlyArray<readonly [number, number]>,
): ToeHit | null {
let best: ToeHit | null = null;
let bestT = Infinity;
for (let i = 0; i < toe.length - 1; i += 1) {
const [ax, ay] = toe[i];
const [bx, by] = toe[i + 1];
const ex = bx - ax;
const ey = by - ay;
const denominator = dx * ey - dy * ex;
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ax - ox) * ey - (ay - oy) * ex) / denominator;
const u = ((ax - ox) * dy - (ay - oy) * dx) / denominator;
const reach = Math.abs(t);
if (reach < 1e-6 || reach > RAY_LIMIT_M || u < -1e-9 || u > 1 + 1e-9 || reach >= bestT) {
continue;
}
bestT = reach;
best = { x: ox + dx * t, y: oy + dy * t, segment: i };
}
return best;
}
/**
* 날개벽 **성토끝 박스**(2026-08-26 사용자 협의 확정) — 다른 구조물과 달리 날개벽은
* 성토 바깥까지 이어진 영역을 하나 더 그린다.
*
* · 밑변 = 날개벽 **시작 모서리 → 끝 모서리**.
* · 양 옆선 = 그 두 점에서 **구조물 축과 평행**하게 바깥으로. 축은 측점 좌향
* 벡터(노선 수직)의 **고정 직선**이다 — 노선 곡률을 따라 각도가 바뀌지 않는다.
* · 바깥 변 = 두 교점 사이의 **원본 성토면 최하단 끝선** 그대로.
*/
export function wingBoxLoops(
set: ReadonlyArray<CorridorStructure>,
side: Side,
planZ: number,
axis: StationAxis,
toe: ReadonlyArray<readonly [number, number]>,
): Array<Array<[number, number, number]>> {
if (toe.length < 2) return [];
const sign = side === "left" ? 1 : -1;
const dx = axis.leftX * sign;
const dy = axis.leftY * sign;
const loops: Array<Array<[number, number, number]>> = [];
for (const structure of set) {
const rings = structure.rings;
if (!structure.wing || !rings || rings.length < 2) continue;
if (wingSide(rings, axis) !== side) continue;
const start = rings[0];
const end = rings[rings.length - 1];
const startHit = castToToe(start.cx, start.cy, dx, dy, toe);
const endHit = castToToe(end.cx, end.cy, dx, dy, toe);
if (!startHit || !endHit) continue;
const loop: Array<[number, number, number]> = [
[start.cx, start.cy, planZ],
[end.cx, end.cy, planZ],
[endHit.x, endHit.y, planZ],
];
// 두 교점 사이 성토 끝선을 그대로 탄다 — 마디 번호 순서대로 훑는다.
const step = endHit.segment <= startHit.segment ? 1 : -1;
for (
let i = endHit.segment + (step > 0 ? 1 : 0);
step > 0 ? i <= startHit.segment : i > startHit.segment;
i += step
) {
loop.push([toe[i][0], toe[i][1], planZ]);
}
loop.push([startHit.x, startHit.y, planZ]);
loop.push(loop[0]);
loops.push(loop);
}
return loops;
}
@@ -73,6 +73,12 @@ export interface CorridorStructure {
polygons?: Array<Array<[number, number]>>;
/** 스윕 프레임 열(누가거리 오름차순) — 폴리곤을 각 프레임에 앉혀 로프트한다. */
rings?: StructureFrame[];
/**
* 날개벽 표식 — 링이 **노선이 아니라 날개 축**을 탄다(2026-08-26). 그래서 링
* 폴리곤의 편거리는 노선 편거리가 아니라 **패널 두께**다. 탑뷰 투영에서 도로부
* 클립을 적용하면 안 되고(링이 이미 노견 밖), 성토끝 박스를 하나 더 그린다.
*/
wing?: true;
/** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m), 관벽 두께(m·중공 튜브). */
pipe?: {
start: [number, number];
@@ -83,7 +83,7 @@ export function buildWingSolids(
[-thicknessM / 2, anchor.bottomElevation],
]);
}
solids.push({ chainage_m: chainageM, kind, polygons, rings });
solids.push({ chainage_m: chainageM, kind, polygons, rings, wing: true });
}
}
return solids;