feat(B05): 세월교 날개벽을 노선 흐름에 태우고 바닥 에이프런 추가

세월교 측벽·바닥판은 ringsOf(half, half)로 노선 스플라인을 따라가는데 날개벽만
기준 측점 프레임에서 직선으로 ±월류폭/2 자리에 뿌리를 잡아, 곡선 구간에서 측벽
끝단과 어긋났다. BOX암거는 직선 규칙이 맞으므로 건드리지 않는다(사용자 ②).

- `buildWingSolids`에 선택 인자 `endFrameAt` 추가 — 넘기면 날개 뿌리와 축을
  측벽 끝단부 자리의 노선 프레임에서 뽑는다. 세월교만 `frameAt`을 넘긴다.
  안 넘기면 계산이 옛 식과 완전히 같다(BOX 무변화).
- `WingAnchor.apron` 추가 — 세월교 바닥 에이프런. 날개 끝점과 바닥판 종방향
  끝점을 이은 직선을 외곽으로 하는 평면 삼각형을 바닥 두께로 세운다(사용자 ④).
  링을 뿌리→날개끝으로 깔고 링 좌향을 뿌리→바닥판끝으로 잡아 폭을 |P0P2| → 0으로
  좁히는 방식이라 기존 로프트 모델을 그대로 쓴다.
- 에이프런도 `wing` 표식을 달므로 성토끝 박스 대상은 `kind === "revet"`로 좁혔다.
- `BUILD_VERSION` 33 → 34.

실측: BOX 200.92 날개 뿌리 이동량 0.000m·에이프런 0개(무변화 확인). 세월교
149.73 뿌리 이동량 0.102~1.119m, 뿌리가 측벽 끝단 링 횡단선 위(종방향 잔차 0).
에이프런 4장, 바깥 모서리가 기준 직선 위 이탈 0.0000m. 성토끝 박스는 두 세트
모두 4/4 — 앞서 막혀 있던 149.73 우측 날개 1장도 뿌리 이동으로 풀렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 18:06:33 +09:00
co-authored by Claude Opus 5
parent 2da4ecb038
commit bad291f720
4 changed files with 85 additions and 11 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ function fnv1a(text: string): string {
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
* 섞어 두면 배포와 동시에 저장본이 만료된다.
*/
const BUILD_VERSION = 33; // 날개벽 판 투영 + 성토끝 박스(2026-08-26).
const BUILD_VERSION = 34; // 세월교 날개벽 노선 추종 + 바닥 에이프런(2026-08-26).
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
@@ -214,7 +214,8 @@ export function wingBoxLoops(
const loops: Array<Array<[number, number, number]>> = [];
for (const structure of set) {
const rings = structure.rings;
if (!structure.wing || !rings || rings.length < 2) continue;
// 날개벽(revet)만 — 세월교 바닥 에이프런(basin)도 `wing` 표식을 달지만 박스는 없다.
if (!structure.wing || structure.kind !== "revet" || !rings || rings.length < 2) continue;
if (wingSide(rings, axis) !== side) continue;
const start = rings[0];
const end = rings[rings.length - 1];
@@ -428,6 +428,12 @@ export function buildCorridorStructures(
const wall = side.parts.find((part) => part.kind === "wall");
const floor = side.parts.find((part) => part.kind === "floor");
if (!wall || !floor) return [];
// 바닥판 종방향 끝의 계류측 모서리 — 그 측 바깥 방향으로 가장 먼 점이다.
const outerOffset = floor.points.reduce(
(far, point) =>
point.offset * side.outward > far * side.outward ? point.offset : far,
floor.points[0].offset,
);
return [
{
// 벽 외측면 하단 모서리(points[3] = 하단-전면) — 여기서 벌어져 나간다.
@@ -435,9 +441,18 @@ export function buildCorridorStructures(
topElevation: wall.points[2].elevation,
bottomElevation: floor.points[2].elevation,
wing: side.role === "inlet" ? fordLayout.ford.wing_in : fordLayout.ford.wing_out,
// 바닥 에이프런 — 날개 끝점↔바닥판 끝점 직선이 외곽이다(2026-08-26 ④).
apron: {
outerOffset,
topElevation: side.slabTopElevation,
bottomElevation: floor.points[2].elevation,
},
},
];
}),
"revet",
// 세월교만 — 날개 뿌리·각도를 측벽 끝단부 자리의 노선 프레임에서 뽑는다.
frameAt,
),
);
// 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련).
@@ -13,7 +13,11 @@
* ========================================================================== */
import type { FordWingSpec } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
import type {
CorridorStructure,
RouteFrame,
StructureFrame,
} from "./B05_Profile_UI_Corridor_Structures";
import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
/** 날개벽이 붙는 구조물 한쪽 끝 — 좌·우 각각 하나. */
@@ -26,6 +30,17 @@ export interface WingAnchor {
bottomElevation: number;
/** 그 측에 적용할 날개벽 제원. */
wing: FordWingSpec;
/**
* 세월교 전용 — 바닥 에이프런(2026-08-26 사용자 ④). 있으면 **날개 끝점과 바닥판
* 종방향 끝점을 이은 직선**을 외곽으로 하는 평면 삼각형을 바닥 두께로 세운다.
* BOX암거는 안 넘긴다(현행 유지 — 사용자 ②).
*/
apron?: {
/** 바닥판 종방향 끝의 계류측 모서리 offset. */
outerOffset: number;
topElevation: number;
bottomElevation: number;
};
}
/** 날개 축 방향 분할 수 — 높이 체감을 담을 최소 해상도. */
@@ -44,10 +59,13 @@ export function buildWingSolids(
thicknessM: number,
anchors: WingAnchor[],
kind: CorridorStructure["kind"] = "revet",
/**
* 넘기면 날개 뿌리·축을 **측벽 끝단부 자리의 노선 프레임**에서 뽑는다 —
* 세월교는 곡선에서 측벽이 노선을 따라 휘므로 직선 규칙이면 끝단과 어긋난다
* (2026-08-26 사용자 ③). 안 넘기면 현행 그대로 = BOX암거(사용자 ②).
*/
endFrameAt?: (chainageM: number) => RouteFrame | null,
): CorridorStructure[] {
// 노선 진행 방향 = 좌향 벡터를 90° 돌린 것.
const dirX = -frame.leftY;
const dirY = frame.leftX;
const solids: CorridorStructure[] = [];
for (const anchor of anchors) {
@@ -60,12 +78,19 @@ export function buildWingSolids(
const height0 = anchor.topElevation - anchor.bottomElevation;
const height1 = Math.max(wing.height_m ?? height0, 0.3);
for (const along of [1, -1]) {
// 모서리 = 측점 프레임에서 진행 방향으로 반폭, 좌향으로 구체 끝 offset.
const startX = frame.cx + dirX * along * halfSpanM + frame.leftX * anchor.offset;
const startY = frame.cy + dirY * along * halfSpanM + frame.leftY * anchor.offset;
// 뿌리를 낼 프레임 — 세월교는 측벽 끝단 자리의 노선 프레임, BOX는 기준 측점.
const end = endFrameAt?.(chainageM + along * halfSpanM) ?? null;
const base = end ?? frame;
// 노선 진행 방향 = 좌향 벡터를 90° 돌린 것.
const dirX = -base.leftY;
const dirY = base.leftX;
// 끝단 프레임을 쓰면 그 자리가 이미 측점 ± 반폭이라 진행 방향 이동이 없다.
const reach = end ? 0 : halfSpanM;
const startX = base.cx + dirX * along * reach + base.leftX * anchor.offset;
const startY = base.cy + dirY * along * reach + base.leftY * anchor.offset;
// 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터.
const wingX = frame.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle);
const wingY = frame.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle);
const wingX = base.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle);
const wingY = base.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle);
const wingNorm = Math.hypot(wingX, wingY) || 1;
const ux = wingX / wingNorm;
const uy = wingY / wingNorm;
@@ -84,6 +109,39 @@ export function buildWingSolids(
]);
}
solids.push({ chainage_m: chainageM, kind, polygons, rings, wing: true });
const apron = anchor.apron;
if (!apron) continue;
// 바닥판 종방향 끝의 계류측 모서리까지의 폭 — 뿌리에서 그만큼 벌어져 있다.
const width = apron.outerOffset - anchor.offset;
if (Math.abs(width) < 0.01) continue;
const spread = Math.sign(width);
const apronRings: StructureFrame[] = [];
const apronPolygons: SectionPolygon[] = [];
for (let i = 0; i <= WING_STEPS; i += 1) {
const t = i / WING_STEPS;
// 링 좌향 = 뿌리→바닥판 끝 방향. 폭을 |P0P2| → 0으로 좁히면 P0·P2·P1 삼각형이다.
apronRings.push({
cx: startX + ux * length * t,
cy: startY + uy * length * t,
leftX: base.leftX * spread,
leftY: base.leftY * spread,
dz: 0,
});
const span = Math.abs(width) * (1 - t);
apronPolygons.push([
[0, apron.bottomElevation],
[span, apron.bottomElevation],
[span, apron.topElevation],
[0, apron.topElevation],
]);
}
solids.push({
chainage_m: chainageM,
kind: "basin",
polygons: apronPolygons,
rings: apronRings,
wing: true,
});
}
}
return solids;