diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 0239e426..a91ec06e 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 = 32; // 구조물 탑뷰 풋프린트 재구현 — 부재별 표기(2026-08-26). +const BUILD_VERSION = 33; // 날개벽 판 투영 + 성토끝 박스(2026-08-26). /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index e7e81928..bb1bab1a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -40,6 +40,8 @@ const PLAN_CURVE_COLORS: Record = { structure: 0xffd93d, "slope-deformed": 0xff2d78, "slope-original": 0x38bdf8, + // 날개벽 성토끝 박스 — 구조물 노랑과 구분되게 주황(2026-08-26 사용자). + "wing-box": 0xff8c1a, }; /** 유출측 파선 눈금(m) — 유입은 실선이다. */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index 0acfcc2c..f261e517 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -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): 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)) { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts index 48b66d7f..51e26a28 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts @@ -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, 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, side: Side, roadEdgeOffset: number, planZ: number, + axis: StationAxis, ): Array> { const loops: Array> = []; 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, +): 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, + side: Side, + planZ: number, + axis: StationAxis, + toe: ReadonlyArray, +): Array> { + if (toe.length < 2) return []; + const sign = side === "left" ? 1 : -1; + const dx = axis.leftX * sign; + const dy = axis.leftY * sign; + const loops: Array> = []; + 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; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 10abed4c..8a43b33d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -73,6 +73,12 @@ export interface CorridorStructure { polygons?: Array>; /** 스윕 프레임 열(누가거리 오름차순) — 폴리곤을 각 프레임에 앉혀 로프트한다. */ rings?: StructureFrame[]; + /** + * 날개벽 표식 — 링이 **노선이 아니라 날개 축**을 탄다(2026-08-26). 그래서 링 + * 폴리곤의 편거리는 노선 편거리가 아니라 **패널 두께**다. 탑뷰 투영에서 도로부 + * 클립을 적용하면 안 되고(링이 이미 노견 밖), 성토끝 박스를 하나 더 그린다. + */ + wing?: true; /** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m), 관벽 두께(m·중공 튜브). */ pipe?: { start: [number, number]; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts index 5c8e3084..15333d84 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts @@ -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;