From bad291f720a02acfbaf3c0857ed7599973a50a19 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 26 Aug 2026 18:06:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=EB=82=A0=EA=B0=9C=EB=B2=BD=EC=9D=84=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=EC=97=90=20=ED=83=9C=EC=9A=B0=EA=B3=A0=20?= =?UTF-8?q?=EB=B0=94=EB=8B=A5=20=EC=97=90=EC=9D=B4=ED=94=84=EB=9F=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세월교 측벽·바닥판은 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) --- B05_Profile/B05_Profile_UI_Corridor.ts | 2 +- .../B05_Profile_UI_Corridor_Plan_Footprint.ts | 3 +- .../B05_Profile_UI_Corridor_Structures.ts | 15 ++++ ...B05_Profile_UI_Corridor_Structures_Wing.ts | 76 ++++++++++++++++--- 4 files changed, 85 insertions(+), 11 deletions(-) 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;