diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index a91ec06e..f85530d0 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts index 51e26a28..5b3035fa 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts @@ -214,7 +214,8 @@ export function wingBoxLoops( const loops: Array> = []; 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]; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 8a43b33d..9137ceb5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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개만 보이지만 실물은 여러 련). diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts index 15333d84..b51c7a20 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts @@ -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;