feat(B05): 구조물 탑뷰 투영 커브 재작성 — 세트별 상단+5m 평면·유입/유출 분리

구조물 추가 뒤 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:42:35 +09:00
co-authored by Claude Opus 5
parent f6296fa7c5
commit 5a3b2ebe39
5 changed files with 311 additions and 333 deletions
+15 -3
View File
@@ -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<Corridor
});
if (!response.ok) return null;
const payload = (await response.json()) as CorridorEnvelope;
return payload && payload.version === 1 && Array.isArray(payload.ribbons) ? payload : null;
return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)
? payload
: null;
} catch {
return null; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
}
+10 -98
View File
@@ -45,16 +45,9 @@ import {
type RouteFrame,
} from "./B05_Profile_UI_Corridor_Structures";
export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
import {
assembleCarveTraces,
assembleStripLoops,
buildCarveFootprint,
buildRoleLookup,
buildStructureOnlyFootprint,
buildStructurePatchRibbons,
elevationOnPolyline,
projectFootprintToPlane,
} from "./B05_Profile_UI_Corridor_Carve";
import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve";
import { buildPlanCurves, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
export interface CorridorRibbon {
@@ -92,21 +85,11 @@ export interface CorridorBuildResult {
/** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */
structures: CorridorStructure[];
/**
* 절단 투영선(2026-08-25 사용자 — 표기 모드): 구조물·패치 풋프린트 경계를 원본
* 성토면 표고에 얹은 폴리라인들(모델 좌표). 저장 코리도엔 없을 수 있다.
* 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자)세트 구조물 상단 5m
* 평면에 눕힌 닫힌 고리들. 구조물·비탈(변형/원본)을 유입·유출로 나눠 담는다.
* 저장 코리도엔 없을 수 있다.
*/
carveTraces?: Array<Array<[number, number, number]>>;
/** 구조물(기슭막이·집수정·구체·날개벽) 탑뷰 외곽선 — 성토라인과 **따로** 그린다. */
structurePlan?: Array<Array<[number, number, number]>>;
/**
* 성토부 **탑뷰 외곽선**(2026-08-25 사용자) — **구조물이 없다고 보고** 원본
* 성토·절토면의 바깥 윤곽을 유입/유출로 나눠 담은 닫힌 고리들. z는 0이고 그리기
* 쪽이 평면에 얹는다.
*/
fillPlan?: {
inlet: Array<Array<[number, number, number]>>;
outlet: Array<Array<[number, number, number]>>;
};
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<string, OffsetPoint[]>();
// 이 행의 절단 투영점(측별) — 비탈 표면 위 표고로.
const rowTraces: Partial<Record<"left" | "right", OffsetPoint>> = {};
/** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */
const fixedSlopeKeys = new Set<string>();
/** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */
@@ -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),
};
}
+6 -162
View File
@@ -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<readonly [number, number]>): [number, number] | null {
export function ringEdges(
polygon: ReadonlyArray<readonly [number, number]>,
): [number, number] | null {
if (!polygon.length) return null;
let min = Infinity;
let max = -Infinity;
@@ -198,163 +199,6 @@ export function buildRevetFootprint(structures: ReadonlyArray<CorridorStructure>
return footprint;
}
/**
* 탑뷰 투영 풋프린트를 만든다 — 구조물 솔리드의 링 사이 사각형 + 실루엣 패치 영역.
*/
export function buildCarveFootprint(
crossSections: ReadonlyArray<CrossSection>,
structures: ReadonlyArray<CorridorStructure>,
frameAt: (chainageM: number) => RouteFrame | null,
): CarveFootprint {
const footprint = new CarveFootprint();
addStructureQuads(footprint, structures);
addDeformedFillQuads(footprint, crossSections, frameAt);
return footprint;
}
/** 구조물 솔리드만의 투영 풋프린트 — 표기용(성토라인과 **따로** 그린다). */
export function buildStructureOnlyFootprint(
structures: ReadonlyArray<CorridorStructure>,
): CarveFootprint {
const footprint = new CarveFootprint();
addStructureQuads(footprint, structures);
return footprint;
}
/** 실루엣 패치 영역 — [확장 노견 → 실루엣 바깥 끝]을 점유 구간 내내(성토부 하단까지). */
function addDeformedFillQuads(
footprint: CarveFootprint,
crossSections: ReadonlyArray<CrossSection>,
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<Array<[number, number, number]>> {
const traces: Array<Array<[number, number, number]>> = [];
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<Array<[number, number, number]>> {
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<CrossSection>,
): (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<CrossSection>): 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 = 끊김)을 **닫힌 외곽 고리**로 잇는다 — 안쪽을
* 앞으로, 바깥쪽을 뒤로 이어 닫는다. 내부 칸막이는 안 생긴다.
+27 -70
View File
@@ -19,6 +19,7 @@ import type {
CorridorKind,
CorridorRibbon,
CorridorStructure,
PlanCurve,
} from "./B05_Profile_UI_Corridor_Build";
/**
@@ -34,21 +35,16 @@ const KIND_COLORS: Record<CorridorKind, number> = {
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<PlanCurve["source"], number> = {
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<Array<[number, number, number]>>;
}> = [{ 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;
+253
View File
@@ -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<Array<[number, number, number]>>;
}
/** 구조물 상단에서 투영 평면을 띄우는 높이(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<CorridorStructure>): 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<CorridorStructure>,
side: Side,
roadEdgeOffset: number,
planZ: number,
): Array<Array<[number, number, number]>> {
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<Array<[number, number, number]>> {
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<CrossSection>,
structures: ReadonlyArray<CorridorStructure>,
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<Array<[number, number, number]>>,
): 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;
}