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:
@@ -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) — 유입은 실선이다. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user