From 5a3b2ebe39e43e131752ffef4c6134896d691092 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 26 Aug 2026 09:42:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=ED=83=91=EB=B7=B0=20=ED=88=AC=EC=98=81=20=EC=BB=A4=EB=B8=8C=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1=20=E2=80=94=20=EC=84=B8=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=83=81=EB=8B=A8+5m=20=ED=8F=89=EB=A9=B4=C2=B7?= =?UTF-8?q?=EC=9C=A0=EC=9E=85/=EC=9C=A0=EC=B6=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물 추가 뒤 3D 투영 영역이 제대로 안 만들어지던 문제. 평면고가 구조물과 무관한 모델 전체 최고표고였고, 구조물 커브와 비탈 커브가 다른 기준으로 따로 나왔으며, 성토면 표면 위에도 절단 투영선이 겹쳐 그려져 이중 표기였다. - `_Corridor_Plan.ts` 신규 — 구조물 세트(같은 측점 소유)별로 투영 평면을 그 세트 구조물 최고표고 +5m에 잡고, 유입/유출을 나눠 커브를 만든다. 세트당 구조물·비탈(변형 실루엣)·비탈(원본 성토선) × 유입/유출. 도로부(노견 안쪽)는 전 커브에서 빼 좌우가 끊긴 두 루프가 된다. - `_Corridor_Carve.ts` — 투영 전용 함수(풋프린트 평면 투영·유입유출 판정· 절단 트레이스 조립) 삭제, `ringEdges` 공개. 패치 리본 경로만 남겼다. - `_Corridor_Build.ts` — 성토면 위 절단 투영선(`carveTraces`) 생성 삭제, `structurePlan`/`fillPlan` → `planCurves` 하나로 교체. - `_Corridor_Mesh.ts` — 표면 위 투영선 그리기 삭제. 커브를 각자 평면고에 종류별 색(구조물 노랑/변형 분홍/원본 하늘), 유입 실선·유출 파선으로 그린다. - `_Corridor.ts` — 저장 코리도 봉투에 `planCurves` 포함, 형식 버전 1→2. 안 담으면 저장본으로 복원한 코리도에서 커브가 통째로 사라진다. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 18 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 108 +------- B05_Profile/B05_Profile_UI_Corridor_Carve.ts | 168 +----------- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 97 ++----- B05_Profile/B05_Profile_UI_Corridor_Plan.ts | 253 +++++++++++++++++++ 5 files changed, 311 insertions(+), 333 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Plan.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 19248d35..34a221c5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -29,8 +29,11 @@ export interface CorridorStructureHook { applies: (chainageStart: number, chainageEnd: number) => boolean; } +/** 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다. */ +const ENVELOPE_VERSION = 2; + interface CorridorEnvelope { - version: 1; + version: number; hash: string; ribbons: Array<{ kind: CorridorRibbon["kind"]; @@ -44,6 +47,11 @@ interface CorridorEnvelope { caps?: CorridorBuildResult["caps"]; /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ structures?: CorridorBuildResult["structures"]; + /** + * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 + * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. + */ + planCurves?: CorridorBuildResult["planCurves"]; } interface CacheEntry { @@ -129,7 +137,7 @@ function floatsFromBase64(encoded: string): Float64Array { function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { return { - version: 1, + version: ENVELOPE_VERSION, hash, ribbons: build.ribbons.map((ribbon) => ({ kind: ribbon.kind, @@ -141,6 +149,7 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { outline: build.outline, caps: build.caps, structures: build.structures, + planCurves: build.planCurves, }; } @@ -156,6 +165,7 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { outline: envelope.outline, caps: envelope.caps ?? [], structures: envelope.structures ?? [], + planCurves: envelope.planCurves ?? [], }; } @@ -181,7 +191,9 @@ async function fetchStored(projectId: string, routeId: number): Promise>; - /** 구조물(기슭막이·집수정·구체·날개벽) 탑뷰 외곽선 — 성토라인과 **따로** 그린다. */ - structurePlan?: Array>; - /** - * 성토부 **탑뷰 외곽선**(2026-08-25 사용자) — **구조물이 없다고 보고** 원본 - * 성토·절토면의 바깥 윤곽을 유입/유출로 나눠 담은 닫힌 고리들. z는 0이고 그리기 - * 쪽이 평면에 얹는다. - */ - fillPlan?: { - inlet: Array>; - outlet: Array>; - }; + planCurves?: PlanCurve[]; } /** @@ -170,19 +153,6 @@ export function buildCorridor( outerLeft: OffsetPoint; outerRight: OffsetPoint; }> = []; - /** 절단 투영선 점(행 순서, 없는 행은 null) — 마지막에 폴리라인으로 잇는다. */ - const traceRows: Record<"left" | "right", Array<[number, number, number] | null>> = { - left: [], - right: [], - }; - /** - * 성토면 외곽선용 행 가장자리(안쪽·바깥쪽) — 유입/유출 따로. 구조물이 없다고 보고 - * 원본 성토면을 그대로 읽는다(2026-08-25 사용자). - */ - const fillPlanRows: Record< - "inlet" | "outlet", - Array<[[number, number, number], [number, number, number]] | null> - > = { inlet: [], outlet: [] }; const toModel = (row: (typeof rows)[number], point: OffsetPoint): [number, number, number] => [ row.center.x + row.left.x * point.offset_m, @@ -251,12 +221,6 @@ export function buildCorridor( }; }; const structures = buildCorridorStructures(crossSections, frameAt); - // 구조물 탑뷰 투영 풋프린트(2026-08-25 사용자: 순수 평면 투영 절단) — 행 횡단선과 - // 실좌표로 교차시켜, 덮인 데까지 절·성토 조각을 잘라낸다. - const carveFootprint = buildCarveFootprint(crossSections, structures, frameAt); - // 구조물 구간의 측별 유입/유출 — 상단측(uphill)이 유입이다. 성토면 외곽선을 - // 유입·유출로 나눠 그리는 데 쓴다(2026-08-25 사용자). - const roleAt = buildRoleLookup(crossSections); for (let i = 0; i < stations.length - 1; i += 1) { const s0 = stations[i]; @@ -293,8 +257,6 @@ export function buildCorridor( ((profileZ(s1.chainage_m) ?? 0) - (profileZ(s0.chainage_m) ?? 0)) * t) : 0; const sections = new Map(); - // 이 행의 절단 투영점(측별) — 비탈 표면 위 표고로. - const rowTraces: Partial> = {}; /** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */ const fixedSlopeKeys = new Set(); /** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */ @@ -456,27 +418,6 @@ export function buildCorridor( sections.delete(slopeKey); return; } - // 절단 투영선(2026-08-25 사용자: 절단하지 말고 **원본 성토면 위에 투영선만 - // 표기**) — 이 행의 횡단선이 구조물·패치 풋프린트에 덮이는 가장 바깥 - // 편거리를 구해, 비탈 표면 표고에 얹은 점으로 남긴다. 행마다 이어지면 - // 투영 경계선이 된다. 서피스는 자르지 않는다. - if (!carveFootprint.isEmpty) { - const outerCov = carveFootprint.outerAt( - center.x, - center.y, - left.x, - left.y, - side, - trimmed[0].offset_m, - trimmed[trimmed.length - 1].offset_m, - ); - if (outerCov !== null) { - rowTraces[side] = { - offset_m: outerCov, - elevation_m: elevationOnPolyline(trimmed, outerCov), - }; - } - } const rescaled = resample(trimmed, PIECE_COLS[kind]); if (side === "right") rescaled.reverse(); sections.set(slopeKey, rescaled); @@ -506,27 +447,6 @@ export function buildCorridor( outerLeft: outerOf("left"), outerRight: outerOf("right"), }); - // 투영선 점 — 행 좌표계(center+left)로 모델 XY에 얹는다. 없는 행은 null(선 끊김). - const row = rows[rows.length - 1]; - (["left", "right"] as const).forEach((side) => { - const hit = rowTraces[side]; - traceRows[side].push(hit ? [...toModel(row, hit)] : null); - // 성토면(구조물 없다고 본 원본) 외곽선 — 유입·유출로 나눠 담는다. - const role = roleAt(side, chainage); - const piece = - sections.get(pieceKey("fill", side)) ?? sections.get(pieceKey("cut", side)) ?? null; - for (const bucketRole of ["inlet", "outlet"] as const) { - const active = role === bucketRole && piece !== null && piece.length >= 2; - fillPlanRows[bucketRole].push( - active - ? [ - toModel(row, side === "right" ? piece![piece!.length - 1] : piece![0]), - toModel(row, side === "right" ? piece![0] : piece![piece!.length - 1]), - ] - : null, - ); - } - }); } } if (rows.length < 2) return null; @@ -623,16 +543,8 @@ export function buildCorridor( outline, caps, structures, - // 투영선 — 연속 구간마다 폴리라인 하나(끊긴 행에서 분리). - carveTraces: assembleCarveTraces([...traceRows.left, null, ...traceRows.right]), - // Z 평면 투영 — 표고는 그리기 쪽이 **3D 원지반 최고 표고 위**로 얹는다(2026-08-25 - // 사용자). 여기서는 XY만 확정하고 z는 0으로 둔다. - // 구조물 커브는 **성토면이 없다고 보고** 구조물 솔리드만으로, 성토부 커브는 - // **구조물이 없다고 보고** 원본 성토면만으로 만든다(2026-08-25 사용자). - structurePlan: projectFootprintToPlane(buildStructureOnlyFootprint(structures), 0), - fillPlan: { - inlet: assembleStripLoops(fillPlanRows.inlet), - outlet: assembleStripLoops(fillPlanRows.outlet), - }, + // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 + // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. + planCurves: buildPlanCurves(crossSections, structures, frameAt, outline), }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts index 21ce8d5e..2e9ebda1 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -12,6 +12,9 @@ * · 곡선 구간에서도 절단 경계 = 실제 탑뷰 투영이다. * 원지반에는 쓰지 않는다(사용자: 투영 및 삭제는 원지반 제외). * + * **탑뷰 투영 커브**(구조물 상단 5m 평면)는 여기 없다 — `_Corridor_Plan.ts`로 + * 옮겼다(2026-08-26 사용자 재작성 지시). + * * 풋프린트 구성: * · 구조물 솔리드 — 링과 링 사이의 평면 사각형(실좌표). * · 변형 성토선(실루엣) 패치가 덮을 영역 — 점유 구간을 노선 프레임으로 1m씩 걸어 @@ -27,7 +30,6 @@ import { PIECE_COLS, resample, type CorridorKind, - type OffsetPoint, } from "./B05_Profile_UI_Corridor_Station"; import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; @@ -37,15 +39,14 @@ import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_ /** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */ const MIN_REACH_M = 0.05; -/** 실루엣 패치 풋프린트의 종방향 걸음(m). */ -const PATCH_STEP_M = 1.0; - /** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다. * 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */ const NEAR_LIMIT_M = 30; /** 링 폴리곤의 편거리 양 끝 — 그 링이 평면에서 차지하는 폭. */ -function ringEdges(polygon: ReadonlyArray): [number, number] | null { +export function ringEdges( + polygon: ReadonlyArray, +): [number, number] | null { if (!polygon.length) return null; let min = Infinity; let max = -Infinity; @@ -198,163 +199,6 @@ export function buildRevetFootprint(structures: ReadonlyArray return footprint; } -/** - * 탑뷰 투영 풋프린트를 만든다 — 구조물 솔리드의 링 사이 사각형 + 실루엣 패치 영역. - */ -export function buildCarveFootprint( - crossSections: ReadonlyArray, - structures: ReadonlyArray, - frameAt: (chainageM: number) => RouteFrame | null, -): CarveFootprint { - const footprint = new CarveFootprint(); - addStructureQuads(footprint, structures); - addDeformedFillQuads(footprint, crossSections, frameAt); - return footprint; -} - -/** 구조물 솔리드만의 투영 풋프린트 — 표기용(성토라인과 **따로** 그린다). */ -export function buildStructureOnlyFootprint( - structures: ReadonlyArray, -): CarveFootprint { - const footprint = new CarveFootprint(); - addStructureQuads(footprint, structures); - return footprint; -} - -/** 실루엣 패치 영역 — [확장 노견 → 실루엣 바깥 끝]을 점유 구간 내내(성토부 하단까지). */ -function addDeformedFillQuads( - footprint: CarveFootprint, - crossSections: ReadonlyArray, - frameAt: (chainageM: number) => RouteFrame | null, -): void { - const at = (frame: RouteFrame, offset: number): PXY => ({ - x: frame.cx + frame.leftX * offset, - y: frame.cy + frame.leftY * offset, - }); - for (const section of crossSections) { - const edges = section.design?.road_edges; - if (!edges) continue; - const fallback: RouteFrame = { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - designZ: null, - }; - for (const silhouette of structureSilhouettes(section)) { - const sign = silhouette.side === "left" ? 1 : -1; - // 노선·노폭·**확장된 노견까지는 구조물이 아니다**(2026-08-25 사용자) — 투영 - // 안쪽 경계를 확장 노견 바깥 끝으로 잡아 도로부를 윤곽에서 뺀다. - const roadEdge = silhouette.side === "left" ? edges.left.offset_m : edges.right.offset_m; - const inner = silhouette.widenTo?.offset_m ?? roadEdge; - let outer = inner; - for (const point of silhouette.points) { - if ((point.offset_m - outer) * sign > 0) outer = point.offset_m; - } - if ((outer - inner) * sign <= MIN_REACH_M) continue; - const from = section.chainage_m - silhouette.span.beforeM; - const to = section.chainage_m + silhouette.span.afterM; - const steps: number[] = []; - for (let atM = from; atM < to; atM += PATCH_STEP_M) steps.push(atM); - steps.push(to); - footprint.addStrip( - steps.map((atM) => { - const frame = frameAt(atM) ?? fallback; - return [at(frame, inner), at(frame, outer)] as [PXY, PXY]; - }), - ); - } - } -} - -/** 비탈 폴리라인(안→밖)에서 편거리 자리의 표면 표고 — 투영선을 표면에 얹는다. */ -export function elevationOnPolyline(points: OffsetPoint[], offset: number): number { - for (let index = 1; index < points.length; index += 1) { - const a = points[index - 1]; - const b = points[index]; - if ((offset - a.offset_m) * (offset - b.offset_m) <= 0) { - const span = b.offset_m - a.offset_m; - const t = Math.abs(span) < 1e-12 ? 0 : (offset - a.offset_m) / span; - return a.elevation_m + (b.elevation_m - a.elevation_m) * t; - } - } - return points[points.length - 1].elevation_m; -} - -/** 행 순서 점 목록(null = 끊김)을 연속 구간별 폴리라인으로 잇는다. */ -export function assembleCarveTraces( - rows: ReadonlyArray<[number, number, number] | null>, -): Array> { - const traces: Array> = []; - let run: Array<[number, number, number]> = []; - for (const point of rows) { - if (point) { - run.push(point); - } else if (run.length) { - if (run.length >= 2) traces.push(run); - run = []; - } - } - if (run.length >= 2) traces.push(run); - return traces; -} - -/** - * **Z 평면 투영 외곽선**(2026-08-25 사용자) — 구조물과 변형된 성토부를 위에서 본 - * 그대로 특정 표고(Z) 수평면에 눕힌 **외곽 루프**들. 내부 칸막이(격자)는 안 낸다. - * 평면 표고는 3D 원지반 최고 표고 위로 잡아 탑뷰에서 가려지지 않게 한다. - * - * 절단은 하지 않는다 — 어디가 덮이는지 **눈으로 보고 판단**하기 위한 표기다. - */ -export function projectFootprintToPlane( - footprint: CarveFootprint, - planeElevation: number, -): Array> { - return footprint - .outlineLoops() - .map((loop) => - loop.map((point) => [point.x, point.y, planeElevation] as [number, number, number]), - ); -} - -/** 구조물 구간의 측별 유입/유출 — 상단측(uphill)이 유입이다. */ -interface RoleSpan { - side: "left" | "right"; - role: "inlet" | "outlet"; - fromM: number; - toM: number; -} - -/** 측·누가거리 → 유입/유출 판정기. 구조물 구간 밖이면 null. */ -export function buildRoleLookup( - crossSections: ReadonlyArray, -): (side: "left" | "right", chainageM: number) => "inlet" | "outlet" | null { - const spans = buildRoleSpans(crossSections); - return (side, chainageM) => { - for (const span of spans) { - if (span.side !== side) continue; - if (chainageM >= span.fromM - 1e-9 && chainageM <= span.toM + 1e-9) return span.role; - } - return null; - }; -} - -function buildRoleSpans(crossSections: ReadonlyArray): RoleSpan[] { - const spans: RoleSpan[] = []; - for (const section of crossSections) { - const inletSide = (section.uphill_side ?? "left") === "left" ? "left" : "right"; - for (const silhouette of structureSilhouettes(section)) { - spans.push({ - side: silhouette.side, - role: silhouette.side === inletSide ? "inlet" : "outlet", - fromM: section.chainage_m - silhouette.span.beforeM, - toM: section.chainage_m + silhouette.span.afterM, - }); - } - } - return spans; -} - /** * 행 가장자리 쌍(안쪽·바깥쪽, null = 끊김)을 **닫힌 외곽 고리**로 잇는다 — 안쪽을 * 앞으로, 바깥쪽을 뒤로 이어 닫는다. 내부 칸막이는 안 생긴다. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index c8292fc5..e7e81928 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -19,6 +19,7 @@ import type { CorridorKind, CorridorRibbon, CorridorStructure, + PlanCurve, } from "./B05_Profile_UI_Corridor_Build"; /** @@ -34,21 +35,16 @@ const KIND_COLORS: Record = { fill: 0x06b6d4, }; -/** 절단 투영선 색 — 지형·코리도 어느 색과도 안 겹치는 진분홍. */ -const CARVE_TRACE_COLOR = 0xff2d78; - -/** 구조물 탑뷰 외곽선 색 — 성토라인(연두)과 뚜렷이 구분되는 노랑. */ -const STRUCTURE_PLAN_COLOR = 0xffd93d; - -/** Z 평면을 원지반 최고 표고에서 띄우는 높이(m). */ -const CARVE_PLAN_LIFT_M = 2; - -/** 성토면 탑뷰 외곽선 색 — 유입(하늘)/유출(주황)으로 나눈다(2026-08-25 사용자). */ -const FILL_PLAN_COLORS: Record<"inlet" | "outlet", number> = { - inlet: 0x38bdf8, - outlet: 0xfb923c, +/** 탑뷰 투영 커브 색(2026-08-26 사용자) — 종류별. 지형·코리도 색과 안 겹친다. */ +const PLAN_CURVE_COLORS: Record = { + structure: 0xffd93d, + "slope-deformed": 0xff2d78, + "slope-original": 0x38bdf8, }; +/** 유출측 파선 눈금(m) — 유입은 실선이다. */ +const PLAN_DASH_M = { dashSize: 0.6, gapSize: 0.4 }; + /** 마구리(시·종점 절단면) 색 — 절단면임이 드러나게 어둡게. */ const CAP_COLOR = 0x2a2f3a; @@ -362,73 +358,34 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou group.add(line); } }); - // 절단 투영선(2026-08-25 사용자 — 표기 모드) — 원본 성토면 위 경계를 굵은 선으로. - (build.carveTraces ?? []).forEach((trace, index) => { - if (trace.length < 2) return; - const positions: number[] = []; - for (const [x, y, z] of trace) { - positions.push(x - origin.cx, z - origin.cz + 0.06, -(y - origin.cy)); - } - const geometry = new THREE.BufferGeometry().setAttribute( - "position", - new THREE.Float32BufferAttribute(positions, 3), - ); - const line = new THREE.Line( - geometry, - new THREE.LineBasicMaterial({ color: CARVE_TRACE_COLOR }), - ); - line.name = `corridor-carve-trace:${index}`; - group.add(line); - }); - // Z 평면 투영 외곽선(2026-08-25 사용자) — 구조물·변형 성토부를 한 수평면에 눕혀 - // 위에서 본 모양 그대로 보여 준다(표기 전용 — 서피스는 안 건드린다). - // 평면 표고 = **3D 원지반 최고 표고**(모델 bounds 상한) 위 — 탑뷰에서 안 가린다. - const planY = bounds.z[1] + CARVE_PLAN_LIFT_M - origin.cz; - const planSets: Array<{ - name: string; - color: number; - loops: Array>; - }> = [{ name: "structure", color: STRUCTURE_PLAN_COLOR, loops: build.structurePlan ?? [] }]; - planSets.forEach((set) => - set.loops.forEach((loop, index) => { + // 탑뷰 투영 커브(2026-08-26 사용자) — 구조물 세트마다 **그 세트 구조물 상단 +5m** + // 평면에 눕힌 닫힌 고리다. 종류는 색(구조물 노랑 / 변형 성토선 분홍 / 원본 하늘), + // 유입·유출은 실선·파선으로 나눈다. 표기 전용 — 서피스는 안 건드린다. + (build.planCurves ?? []).forEach((curve) => { + const color = PLAN_CURVE_COLORS[curve.source]; + const dashed = curve.role === "outlet"; + curve.loops.forEach((loop, index) => { if (loop.length < 2) return; const positions: number[] = []; for (const [x, y] of loop) { - positions.push(x - origin.cx, planY, -(y - origin.cy)); + positions.push(x - origin.cx, curve.planZ - origin.cz, -(y - origin.cy)); } const geometry = new THREE.BufferGeometry().setAttribute( "position", new THREE.Float32BufferAttribute(positions, 3), ); - const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: set.color })); - line.name = `corridor-plan:${set.name}:${index}`; + const line = new THREE.Line( + geometry, + dashed + ? new THREE.LineDashedMaterial({ color, ...PLAN_DASH_M }) + : new THREE.LineBasicMaterial({ color }), + ); + if (dashed) line.computeLineDistances(); + const station = curve.setChainageM.toFixed(2); + line.name = `corridor-plan:${curve.source}:${curve.role}:${curve.side}:${station}:${index}`; group.add(line); - }), - ); - // 성토면 탑뷰 외곽선(2026-08-25 사용자) — 구조물이 없다고 본 원본 성토·절토면의 - // 바깥 윤곽을 **유입/유출 따로** 그린다. 같은 Z 평면에 눕힌다. - const fillPlan = build.fillPlan; - if (fillPlan) { - (["inlet", "outlet"] as const).forEach((role) => { - fillPlan[role].forEach((loop, index) => { - if (loop.length < 2) return; - const positions: number[] = []; - for (const [x, y] of loop) { - positions.push(x - origin.cx, planY, -(y - origin.cy)); - } - const geometry = new THREE.BufferGeometry().setAttribute( - "position", - new THREE.Float32BufferAttribute(positions, 3), - ); - const line = new THREE.Line( - geometry, - new THREE.LineBasicMaterial({ color: FILL_PLAN_COLORS[role] }), - ); - line.name = `corridor-fill-plan:${role}:${index}`; - group.add(line); - }); }); - } + }); build.caps.forEach((cap, index) => { const geometry = capGeometry(cap, origin); if (!geometry) return; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts new file mode 100644 index 00000000..a97adaa7 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -0,0 +1,253 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Plan.ts + * 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자 확정). + * + * 규칙: + * · 투영 평면 = **구조물 세트당 하나** — 그 세트 구조물 최고 표고 + 5m. + * 세트끼리 높이가 다르다(경사 임도에서 각 구조물 위 5m를 지킨다). + * · 유입/유출을 **따로** 낸다 — 도로 기준으로 유입·유출 형태가 다를 수 있다. + * 상단측(`uphill_side`)이 유입이다. + * · 도로부(노견 안쪽)는 전 커브에서 뺀다 — 좌우가 끊겨 세트당 두 루프가 된다. + * · 구조물 커브와 비탈 커브는 **합치지 않는다**(사용자: 따로 2개). + * · 비탈 커브는 **변형 실루엣**·**원본 성토선** 두 벌을 다 낸다 — 어느 쪽을 + * 남길지는 화면 확인 후 별도 확정(2026-08-26 사용자). + * + * 절단은 하지 않는다 — 어디가 덮이는지 눈으로 보고 판단하기 위한 표기다. + * ========================================================================== */ + +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, CarveFootprint, ringEdges } from "./B05_Profile_UI_Corridor_Carve"; +import type { PXY } from "./B05_Profile_UI_Corridor_Union"; + +export type PlanCurveSource = "structure" | "slope-deformed" | "slope-original"; + +/** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */ +export interface PlanCurve { + /** 세트 소유 측점 누가거리(m) — 세트 식별자. */ + setChainageM: number; + source: PlanCurveSource; + role: "inlet" | "outlet"; + side: "left" | "right"; + /** 투영 평면 표고(모델 z) = 세트 구조물 최고 표고 + 5m. */ + planZ: number; + /** 모델 좌표 닫힌 고리들(z는 전부 planZ). */ + loops: Array>; +} + +/** 구조물 상단에서 투영 평면을 띄우는 높이(m) — 2026-08-26 사용자. */ +const PLANE_LIFT_M = 5; + +/** 비탈 스트립 종방향 걸음(m). */ +const STEP_M = 1.0; + +/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ +const MIN_WIDTH_M = 0.05; + +/** 코리도 바깥 윤곽(행별 좌·우 최외곽 XY) — `CorridorBuildResult.outline`. */ +export interface CorridorOutline { + chainages: number[]; + left: Array<[number, number]>; + right: Array<[number, number]>; +} + +type Side = "left" | "right"; + +/** 편거리를 실좌표로 — 노선 프레임이든 구조물 링 프레임이든 좌향 벡터만 있으면 된다. */ +interface PlanFrame { + cx: number; + cy: number; + leftX: number; + leftY: number; +} + +function at(frame: PlanFrame, offset: number): PXY { + return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset }; +} + +/** 그 측에서 노견 바깥에 남는 편거리 구간 — 도로부를 잘라낸 뒤 폭이 없으면 null. */ +function clipOutsideRoad( + edge: readonly [number, number], + side: Side, + roadEdgeOffset: number, +): [number, number] | null { + const [min, max] = edge; + const lo = side === "left" ? Math.max(min, roadEdgeOffset) : min; + const hi = side === "left" ? max : Math.min(max, roadEdgeOffset); + return hi - lo > MIN_WIDTH_M ? [lo, hi] : null; +} + +/** 세트 구조물의 최고 표고 — 폴리곤 표고에 링 종단 보정(dz)을 더한 값의 최대. */ +function topElevationOf(set: ReadonlyArray): number | null { + let top: number | null = null; + for (const structure of set) { + const rings = structure.rings; + if (!rings || !rings.length) continue; + const perRing = structure.polygons; + const shared = structure.polygon; + rings.forEach((frame, index) => { + const polygon = perRing?.[index] ?? shared; + if (!polygon) return; + for (const [, elevation] of polygon) { + const z = elevation + frame.dz; + if (top === null || z > top) top = z; + } + }); + } + return top; +} + +/** 세트 구조물 솔리드의 그 측 탑뷰 외곽 — 도로부를 뺀 링 사이 사각형의 합집합. */ +function structureLoops( + set: ReadonlyArray, + side: Side, + roadEdgeOffset: number, + planZ: number, +): Array> { + const footprint = new CarveFootprint(); + for (const structure of set) { + // 배관은 구조물 솔리드 윤곽에서 뺀다 — 다른 부재 속을 지나는 부재다. + if (structure.kind === "pipe") continue; + const rings = structure.rings; + if (!rings || rings.length < 2) continue; + const perRing = structure.polygons; + const shared = structure.polygon; + // 링이 끊긴 자리(폴리곤 없음·도로부에 다 잘림)에서 스트립을 나눈다. + let run: Array<[PXY, PXY]> = []; + rings.forEach((frame, index) => { + const edge = ringEdges(perRing?.[index] ?? shared ?? []); + const window = edge ? clipOutsideRoad(edge, side, roadEdgeOffset) : null; + if (!window) { + footprint.addStrip(run); + run = []; + return; + } + run.push([at(frame, window[0]), at(frame, window[1])]); + }); + footprint.addStrip(run); + } + return footprint + .outlineLoops() + .map((loop) => loop.map((point) => [point.x, point.y, planZ] as [number, number, number])); +} + +/** 스트립 행([안쪽, 바깥쪽] XY)을 평면 표고에 얹어 닫힌 고리로 만든다. */ +function stripLoops( + rows: ReadonlyArray<[PXY, PXY] | null>, + planZ: number, +): Array> { + return assembleStripLoops( + rows.map((row) => + row + ? ([ + [row[0].x, row[0].y, planZ], + [row[1].x, row[1].y, planZ], + ] as [[number, number, number], [number, number, number]]) + : null, + ), + ); +} + +/** 두 점이 커브를 만들 만큼 떨어졌나. */ +function apart(a: PXY, b: PXY): boolean { + return Math.hypot(b.x - a.x, b.y - a.y) > MIN_WIDTH_M; +} + +/** + * 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음 + * (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌). + */ +export function buildPlanCurves( + crossSections: ReadonlyArray, + structures: ReadonlyArray, + frameAt: (chainageM: number) => RouteFrame | null, + outline: CorridorOutline, +): PlanCurve[] { + const curves: PlanCurve[] = []; + for (const section of crossSections) { + const set = structures.filter( + (structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6, + ); + if (!set.length) continue; + const top = topElevationOf(set); + if (top === null) continue; + const planZ = top + PLANE_LIFT_M; + const edges = section.design?.road_edges; + if (!edges) continue; + const fallback: RouteFrame = { + cx: section.center_x, + cy: section.center_y, + leftX: section.frame.left_xy[0], + leftY: section.frame.left_xy[1], + designZ: null, + }; + const frameOf = (chainageM: number): RouteFrame => frameAt(chainageM) ?? fallback; + const inletSide: Side = (section.uphill_side ?? "left") === "left" ? "left" : "right"; + const roleOf = (side: Side): PlanCurve["role"] => (side === inletSide ? "inlet" : "outlet"); + // 노견 끝 편거리는 소유 측점 값을 구간 내내 쓴다 — 구간이 수 m라 확폭 변화는 무시. + const roadEdgeOf = (side: Side): number => + side === "left" ? edges.left.offset_m : edges.right.offset_m; + + const push = ( + source: PlanCurveSource, + side: Side, + loops: Array>, + ): void => { + if (!loops.length) return; + curves.push({ + setChainageM: section.chainage_m, + source, + role: roleOf(side), + side, + planZ, + loops, + }); + }; + + (["left", "right"] as const).forEach((side) => { + push("structure", side, structureLoops(set, side, roadEdgeOf(side), planZ)); + }); + + for (const silhouette of structureSilhouettes(section)) { + const side = silhouette.side; + const sign = side === "left" ? 1 : -1; + const roadEdge = roadEdgeOf(side); + // 변형 실루엣 — [노견 확장끝 → 실루엣 바깥끝]. + const inner = silhouette.widenTo?.offset_m ?? roadEdge; + let outer = inner; + for (const point of silhouette.points) { + if ((point.offset_m - outer) * sign > 0) outer = point.offset_m; + } + const from = section.chainage_m - silhouette.span.beforeM; + const to = section.chainage_m + silhouette.span.afterM; + if ((outer - inner) * sign > MIN_WIDTH_M) { + const steps: number[] = []; + for (let atM = from; atM < to; atM += STEP_M) steps.push(atM); + steps.push(to); + push( + "slope-deformed", + side, + stripLoops( + steps.map((atM) => { + const frame = frameOf(atM); + return [at(frame, inner), at(frame, outer)] as [PXY, PXY]; + }), + planZ, + ), + ); + } + // 원본 성토·절토선 — [노견 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다. + const rows: Array<[PXY, PXY] | null> = []; + outline.chainages.forEach((chainageM, index) => { + if (chainageM < from - 1e-9 || chainageM > to + 1e-9) return; + const [ox, oy] = side === "left" ? outline.left[index] : outline.right[index]; + const innerXY = at(frameOf(chainageM), roadEdge); + const outerXY = { x: ox, y: oy }; + rows.push(apart(innerXY, outerXY) ? [innerXY, outerXY] : null); + }); + push("slope-original", side, stripLoops(rows, planZ)); + } + } + return curves; +}