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:
@@ -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; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = 끊김)을 **닫힌 외곽 고리**로 잇는다 — 안쪽을
|
||||
* 앞으로, 바깥쪽을 뒤로 이어 닫는다. 내부 칸막이는 안 생긴다.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user