feat(B05·B06): 구조물 3D 투영 표기 · 직교 카메라 · 조정창 템플릿 일원화

3D 구조물과 성토면의 관계를 "잘라내기"에서 "투영 표기"로 바꾸고, 세월교·BOX암거
조정창을 기슭막이 템플릿으로 통일했다.

B05 코리도
- 구조물 탑뷰 투영 산출(_Corridor_Carve.ts 신규): 구조물 솔리드 링 사이 실좌표
  사각형 + 변형 성토면 영역. 노선 파라미터가 아니라 행 횡단선과 실좌표로 교차시켜
  곡선 구간·날개벽 벌어짐이 그대로 잡힌다.
- 사각형 합집합 경계(_Corridor_Union.ts 신규): 변을 교점에서 토막 내고 내부·공유
  토막을 버려 최외곽 윤곽만 남긴다(측정 133 -> 13 루프).
- 표기 모드(CARVE_APPLY=false): 서피스를 자르지 않고 ① 성토면 위 절단 투영선
  ② 원지반 최고 표고 위 Z평면에 구조물 윤곽(노랑)과 유입·유출 성토부 윤곽
  (하늘·주황)을 따로 그린다. 구조물 커브는 성토면 없다고 보고, 성토부 커브는
  구조물 없다고 보고 만든다.
- 구조물 실루엣 공용화(_Corridor_Station_Structure.ts 신규): 기슭막이·BOX암거·
  세월교 실루엣을 한 자료형으로. 성토면 강제 변형은 스위치로 끄고
  (STRUCTURE_SILHOUETTE_OVERRIDE), 노견 확장만 상시 적용(shoulderSpans).
- 평면 영역 분리(_Corridor_Region.ts), 절단기 일반화(clipGeometry가 영역 배열·
  전체 정점 속성 보간 — 리본 UV 유지). 지형은 코리도 스트립만 도려낸다.
- BOX암거 3D를 측점 프레임 직선 압출로(노선 추종 금지), 날개벽 계산 공용화
  (_Corridor_Structures_Wing.ts 신규) 후 세월교에도 날개벽 4매 추가.
- 세월교 보어를 관 련수만큼 뚫고, 보어 밴드를 단면 표고로 클램프해 구멍 찌그러짐
  제거. 날개벽 뿌리를 벽 외측면 모서리로 교정.
- 직교 카메라(_Viewer_Camera.ts 신규) — 원근 왜곡 없이 탑뷰 판독. 커서 피벗 줌이
  직교에서 camera.zoom을 함께 조인다.

B06 횡단
- 조정창 공용 부품(_Cross_Panel_Base.ts 신규): 머리줄·2행 항목·D-pad·키보드
  방향키. 세월교·BOX 조정창을 같은 템플릿으로 옮기고 십자 세트를 창 맨 아래로.
- 세월교 조정창에 날개벽 항목 추가(설치·짧은쪽 높이·길이·각도) — 정본
  pipe_points 되쓰기, 바닥판 연장은 길이×cos각으로 즉시 재계산.
- 세월교 좌우 이동을 집수정 규칙으로: 노견 수평 확장 + 상하 이동분만 성토선.

BUILD_VERSION 19 -> 31(저장 코리도 캐시 무효화). typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 20:03:42 +09:00
co-authored by Claude Opus 5
parent 07fb2ccf46
commit f6296fa7c5
24 changed files with 2569 additions and 858 deletions
+7 -1
View File
@@ -120,7 +120,7 @@ function pivotReticleTexture(): THREE.CanvasTexture {
}
export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera;
camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
controls: OrbitControls;
/** 포인터 이벤트를 받는 캔버스. */
element: HTMLElement;
@@ -253,6 +253,12 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
if (cameraOffset.length() < 0.5 && factor < 1) return;
camera.position.copy(pivot).add(cameraOffset);
controls.target.copy(pivot).add(targetOffset);
// 직교 카메라는 거리로 화면 배율이 안 변한다 — zoom을 같은 비율로 함께 조인다.
// 커서 아래 지점(pivot)의 화면 위치는 옆 이동(offset 스케일)이 이미 지켜 준다.
if ((camera as THREE.OrthographicCamera).isOrthographicCamera) {
camera.zoom /= factor;
camera.updateProjectionMatrix();
}
camera.lookAt(controls.target);
controls.update();
syncPivotMarker();
+1 -1
View File
@@ -76,7 +76,7 @@ function fnv1a(text: string): string {
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
* 섞어 두면 배포와 동시에 저장본이 만료된다.
*/
const BUILD_VERSION = 19; // 유출측 비탈 배열 방향 정규화 — Z 반전 수정(2026-08-24).
const BUILD_VERSION = 31; // 구조물 커브·성토부 커브 분리 산출(2026-08-25).
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+178 -175
View File
@@ -32,6 +32,11 @@ export type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Stati
import {
bracketControls,
buildPieceControls,
buildPolylineSampler,
buildCrossingFinder,
buildProfileSampler,
lerpPoints,
slerpLeft,
type PieceControl,
} from "./B05_Profile_UI_Corridor_Frames";
import {
@@ -40,6 +45,16 @@ 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";
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
export interface CorridorRibbon {
@@ -47,6 +62,11 @@ export interface CorridorRibbon {
side: CorridorSide;
colCount: number;
chainages: number[];
/**
* 구조물 **패치 리본**(2026-08-25) — 투영커브로 잘린 자리에 다시 그리는 변형 성토면.
* 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다).
*/
patch?: boolean;
/**
* rowCount × colCount × 3 (모델 좌표).
*
@@ -71,149 +91,36 @@ export interface CorridorBuildResult {
caps: CorridorCap[];
/** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */
structures: CorridorStructure[];
/**
* 절단 투영선(2026-08-25 사용자 — 표기 모드): 구조물·패치 풋프린트 경계를 원본
* 성토면 표고에 얹은 폴리라인들(모델 좌표). 저장 코리도엔 없을 수 있다.
*/
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]>>;
};
}
/**
* 절단 적용 여부(2026-08-25 사용자) — 지금은 **표기 모드**: 서피스를 자르지 않고
* 원본 성토면 위에 절단 투영선만 그린다. 절단·패치로 되돌리려면 true.
*/
const CARVE_APPLY: boolean = false;
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분).
* 평면 궤적을 스플라인으로 잇게 되어(2026-08-23) 0.1m까지 쪼갤 이유가 없어졌다 —
* 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */
const SUBDIVIDE_STEP_M = 0.2;
/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */
/**
* Centripetal Catmull-Rom 한 구간(p1→p2). 제어점을 지나면서 오버슈트·자기교차가
* 없는 성질이라 급커브(실측 꺾임각 최대 42°)에도 안전하다.
*/
function catmullRom(p0: XY, p1: XY, p2: XY, p3: XY, t: number): XY {
const ALPHA = 0.5; // centripetal
const knot = (previous: number, a: XY, b: XY): number =>
previous + Math.max(1e-6, Math.pow(Math.hypot(b.x - a.x, b.y - a.y), ALPHA));
const t0 = 0;
const t1 = knot(t0, p0, p1);
const t2 = knot(t1, p1, p2);
const t3 = knot(t2, p2, p3);
const time = t1 + (t2 - t1) * t;
const mix = (a: XY, b: XY, ta: number, tb: number): XY => {
const span = tb - ta || 1e-9;
const w = (tb - time) / span;
return { x: a.x * w + b.x * (1 - w), y: a.y * w + b.y * (1 - w) };
};
const a1 = mix(p0, p1, t0, t1);
const a2 = mix(p1, p2, t1, t2);
const a3 = mix(p2, p3, t2, t3);
const b1 = mix(a1, a2, t0, t2);
const b2 = mix(a2, a3, t1, t3);
return mix(b1, b2, t1, t2);
}
/**
* 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 돌려준다.
*
* 정점 사이를 직선으로 이으면 정점 간격(실측 평균 2.6m)만큼 각진다 — 0.1m로 잘게
* 쪼개도 그 각짐은 그대로다(2026-08-23 사용자 지적). 그래서 **스플라인**으로 잇고
* 세분 간격은 0.2m로 되돌린다: 데이터는 절반, 곡선은 훨씬 매끄럽다.
*/
function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: number) => XY) | null {
const points = routePoints.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
if (points.length < 2) return null;
const cumulative: number[] = [0];
for (let i = 1; i < points.length; i += 1) {
cumulative.push(
cumulative[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y),
);
}
const at = (index: number): XY => {
const clamped = Math.min(points.length - 1, Math.max(0, index));
return { x: points[clamped].x, y: points[clamped].y };
};
return (chainage: number): XY => {
const target = Math.min(Math.max(chainage, 0), cumulative[cumulative.length - 1]);
let index = 1;
while (index < points.length - 1 && cumulative[index] < target) index += 1;
const span = cumulative[index] - cumulative[index - 1];
const t = span <= 1e-12 ? 0 : (target - cumulative[index - 1]) / span;
// 양 끝 구간은 바깥 제어점이 없다 — 끝점을 겹쳐 써서 접선이 튀지 않게 한다.
return catmullRom(at(index - 2), at(index - 1), at(index), at(index + 1), t);
};
}
/** 좌측 단위벡터 각도 보간(최단각) — 측점 프레임과 일관된 중간 프레임 방향. */
function slerpLeft(a: XY, b: XY, t: number): XY {
const angleA = Math.atan2(a.y, a.x);
let delta = Math.atan2(b.y, b.x) - angleA;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
const angle = angleA + delta * t;
return { x: Math.cos(angle), y: Math.sin(angle) };
}
function lerpPoints(a: OffsetPoint[], b: OffsetPoint[], t: number): OffsetPoint[] {
return a.map((p, i) => ({
offset_m: p.offset_m + (b[i].offset_m - p.offset_m) * t,
elevation_m: p.elevation_m + (b[i].elevation_m - p.elevation_m) * t,
}));
}
/** 종단 계획선 표고 보간기 — chainage로 계획고를 되짚는다(종단곡선 포함). */
function buildProfileSampler(
samples?: Array<{ chainage_m: number; elevation_m: number }>,
): ((chainage: number) => number | null) | null {
if (!samples || samples.length < 2) return null;
const sorted = [...samples].sort((a, b) => a.chainage_m - b.chainage_m);
return (chainage: number): number | null => {
if (chainage <= sorted[0].chainage_m) return sorted[0].elevation_m;
for (let i = 1; i < sorted.length; i += 1) {
if (chainage <= sorted[i].chainage_m) {
const span = sorted[i].chainage_m - sorted[i - 1].chainage_m;
const t = span <= 1e-12 ? 0 : (chainage - sorted[i - 1].chainage_m) / span;
return sorted[i - 1].elevation_m + (sorted[i].elevation_m - sorted[i - 1].elevation_m) * t;
}
}
return sorted[sorted.length - 1].elevation_m;
};
}
/**
* 계획고와 지반고가 만나는 자리(절토↔성토 전환점)를 찾는 함수를 만든다.
*
* 두 측점 사이 어딘가에서 계획선이 지면선을 통과한다 — 그 자리가 측구·절토의
* 종점이고 거기서부터 성토가 시작된다(2026-08-23 사용자 확정). 종단 계획선 샘플의
* `계획고 - 지반고` 부호가 뒤집히는 구간을 선형보간해 정확한 누가거리를 낸다.
*/
function buildCrossingFinder(
samples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>,
): ((fromM: number, toM: number) => number | null) | null {
const usable = (samples ?? [])
.filter((sample) => typeof sample.ground_elevation_m === "number")
.map((sample) => ({
chainage_m: sample.chainage_m,
difference: sample.elevation_m - (sample.ground_elevation_m as number),
}))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (usable.length < 2) return null;
return (fromM: number, toM: number): number | null => {
const lo = Math.min(fromM, toM);
const hi = Math.max(fromM, toM);
for (let i = 1; i < usable.length; i += 1) {
const a = usable[i - 1];
const b = usable[i];
if (b.chainage_m <= lo || a.chainage_m >= hi) continue;
// 계획고와 지반고가 정확히 같은 샘플 — 구간 **안쪽**일 때만 전환점이다.
// BP·EP는 정의상 지반과 만나므로, 이걸 거르지 않으면 구간 시작점이 전환점으로
// 잡혀 측구·절토가 0.4m 만에 끝났다(2026-08-23 실측).
if (a.difference === 0) {
if (a.chainage_m > lo && a.chainage_m < hi) return a.chainage_m;
continue;
}
if (a.difference * b.difference >= 0) continue;
const span = b.chainage_m - a.chainage_m;
const ratio = span <= 1e-12 ? 0 : a.difference / (a.difference - b.difference);
const crossing = a.chainage_m + span * ratio;
if (crossing > lo && crossing < hi) return crossing;
}
return null;
};
}
/** 종단 보정량 — 도로부(차도·노견·측구)는 계획고에 붙어 있으므로 전량 가산한다.
* 비탈은 상대 좌표라 행 앵커를 통해 보정이 실리고, 끝은 지반 트림이 정한다. */
function applyProfileShift(points: OffsetPoint[], shift: number): void {
@@ -263,6 +170,25 @@ 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,
row.center.y + row.left.y * point.offset_m,
point.elevation_m,
];
/**
* 조각별 종방향 제어점(2026-08-24 사용자: 구조물 구간 끝을 스무스하게).
@@ -273,15 +199,21 @@ export function buildCorridor(
const controlsByKey = new Map<string, PieceControl[]>();
(["left", "right"] as const).forEach((side) => {
const shoulderKey = pieceKey("shoulder", side);
const shoulder = buildPieceControls(stations, side, (station) => {
const piece = station.pieces.get(shoulderKey);
if (!piece) return undefined;
const base = designZ(station.chainage_m);
return piece.map((point) => ({
offset_m: point.offset_m,
elevation_m: point.elevation_m - base,
}));
});
const shoulder = buildPieceControls(
stations,
side,
(station) => {
const piece = station.pieces.get(shoulderKey);
if (!piece) return undefined;
const base = designZ(station.chainage_m);
return piece.map((point) => ({
offset_m: point.offset_m,
elevation_m: point.elevation_m - base,
}));
},
// 노견 확장(2026-08-25 ①)은 wallSpans 없이도 구간 내내 유지돼야 한다.
(station) => station.shoulderSpans?.[side] ?? station.wallSpans?.[side],
);
if (shoulder) controlsByKey.set(shoulderKey, shoulder);
(["cut", "fill"] as const).forEach((kind) => {
const slopeKey = pieceKey(kind, side);
@@ -292,6 +224,40 @@ export function buildCorridor(
});
});
// 구조물 솔리드·절단 경계를 행 루프보다 먼저 만든다 — 행마다 경계로 비탈을 트림한다(2026-08-25).
// 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단과 같은 계산을 그대로 얹는다.
// 스윕 프레임은 코리도 행과 같은 규약(평면 스플라인 + 좌향 slerp)을 쓴다.
const frameAt = (at: number): RouteFrame | null => {
const first = stations[0];
const last = stations[stations.length - 1];
const clamped = Math.min(Math.max(at, first.chainage_m), last.chainage_m);
let index = 0;
while (index < stations.length - 2 && stations[index + 1].chainage_m < clamped) index += 1;
const a = stations[index];
const b = stations[index + 1];
const span = b.chainage_m - a.chainage_m;
const t = span <= 1e-9 ? 0 : (clamped - a.chainage_m) / span;
const xy = sampler?.(clamped) ?? {
x: a.center.x + (b.center.x - a.center.x) * t,
y: a.center.y + (b.center.y - a.center.y) * t,
};
const left = slerpLeft(a.left, b.left, t);
return {
cx: xy.x,
cy: xy.y,
leftX: left.x,
leftY: left.y,
designZ: profileZ?.(clamped) ?? null,
};
};
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];
const s1 = stations[i + 1];
@@ -327,6 +293,8 @@ 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>();
/** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */
@@ -488,6 +456,27 @@ 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);
@@ -517,16 +506,31 @@ 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;
const toModel = (row: (typeof rows)[number], point: OffsetPoint): [number, number, number] => [
row.center.x + row.left.x * point.offset_m,
row.center.y + row.left.y * point.offset_m,
point.elevation_m,
];
const caps: CorridorCap[] = [];
/**
* 측구가 끊기는 자리의 마감벽 — 측구 단면을 **도로 가장자리 높이까지** 세로로
@@ -606,30 +610,29 @@ export function buildCorridor(
});
if (points.length >= 2) caps.push({ points });
});
// 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단과 같은 계산을 그대로 얹는다.
// 스윕 프레임은 코리도 행과 같은 규약(평면 스플라인 + 좌향 slerp)을 쓴다.
const frameAt = (at: number): RouteFrame | null => {
const first = stations[0];
const last = stations[stations.length - 1];
const clamped = Math.min(Math.max(at, first.chainage_m), last.chainage_m);
let index = 0;
while (index < stations.length - 2 && stations[index + 1].chainage_m < clamped) index += 1;
const a = stations[index];
const b = stations[index + 1];
const span = b.chainage_m - a.chainage_m;
const t = span <= 1e-9 ? 0 : (clamped - a.chainage_m) / span;
const xy = sampler?.(clamped) ?? {
x: a.center.x + (b.center.x - a.center.x) * t,
y: a.center.y + (b.center.y - a.center.y) * t,
};
const left = slerpLeft(a.left, b.left, t);
return {
cx: xy.x,
cy: xy.y,
leftX: left.x,
leftY: left.y,
designZ: profileZ?.(clamped) ?? null,
};
// 패치 리본은 표기 모드에서 끈다(2026-08-25 사용자: 절단하지 말고 투영선만) —
// 원본 성토면이 그대로 있어 패치를 겹치면 z-fight만 난다.
if (CARVE_APPLY) {
ribbons.push(
...buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null),
);
}
return {
ribbons,
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),
},
};
return { ribbons, outline, caps, structures: buildCorridorStructures(crossSections, frameAt) };
}
@@ -0,0 +1,499 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Carve.ts
* 구조물이 성토·절토면에서 도려낼 **탑뷰 투영 풋프린트**와, 잘린 자리에 다시 그리는
* **패치 리본**(변형 성토면)을 낸다.
*
* 절단은 **순수 평면 투영**이다(2026-08-25 사용자 재확정: "말 그대로 투영이어야 함 —
* 노선을 따라 컷을 하면 다를 수밖에 없음"). 노선 파라미터(누가거리 구간)로 자르지
* 않고, 각 코리도 행의 횡단선(실좌표 XY 직선)을 구조물·패치의 **실좌표 풋프린트
* 사각형들과 실제로 교차**시켜 그 행에서 덮인 편거리 끝을 얻는다. 그래서
* · 날개벽처럼 배관 구간 뒤에서 넓어지는 부재는 절단 경계도 그 벌어진 윤곽을
* 그대로 따라간다 — 하나의 박스가 아니다(2026-08-25 사용자 ②).
* · 곡선 구간에서도 절단 경계 = 실제 탑뷰 투영이다.
* 원지반에는 쓰지 않는다(사용자: 투영 및 삭제는 원지반 제외).
*
* 풋프린트 구성:
* · 구조물 솔리드 — 링과 링 사이의 평면 사각형(실좌표).
* · 변형 성토선(실루엣) 패치가 덮을 영역 — 점유 구간을 노선 프레임으로 1m씩 걸어
* [노견 → 실루엣 바깥 끝] 사각형(실좌표). 성토부 하단(지반 접점)까지 포함이라
* 실루엣 구간 안 행은 사실상 통째로 지워진다(2026-08-25 사용자 ④).
* ========================================================================== */
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 {
cutInnerTo,
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";
import { unionOutline, unionQuadOf } from "./B05_Profile_UI_Corridor_Union";
import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union";
/** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(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 {
if (!polygon.length) return null;
let min = Infinity;
let max = -Infinity;
for (const [offset] of polygon) {
if (offset < min) min = offset;
if (offset > max) max = offset;
}
return max - min > 1e-6 ? [min, max] : null;
}
/** 구조물·패치의 탑뷰 투영 풋프린트 — 행 횡단선과 교차시켜 절단 범위를 판정한다. */
export class CarveFootprint {
private quads: FootprintQuad[] = [];
/** 스트립(연속 사각형 띠) 원본 — 진단·확장용으로 남겨 둔다. */
private strips: Array<Array<[PXY, PXY]>> = [];
get isEmpty(): boolean {
return this.quads.length === 0;
}
/**
* 스트립 하나 추가 — `edges[i]`는 그 자리의 좌·우 가장자리 두 점이다.
* 이웃한 두 가장자리가 사각형 한 칸이 되고, 전체는 하나의 띠다.
*/
addStrip(edges: ReadonlyArray<[PXY, PXY]>): void {
if (edges.length < 2) return;
for (let i = 0; i < edges.length - 1; i += 1) {
const near = edges[i];
const far = edges[i + 1];
this.quads.push(unionQuadOf(near[0], near[1], far[1], far[0]));
}
this.strips.push(edges.map((edge) => [edge[0], edge[1]]));
}
/**
* **최외곽 윤곽만**(2026-08-25 사용자: 커브들이 이어져 있으면 최외곽만) — 담긴
* 사각형 전체의 **합집합 경계**를 낸다. 서로 겹치거나 맞닿은 조각은 하나의 외곽선이
* 되고, 내부 칸막이·격자는 사라진다.
*
* 방법: 모든 변을 서로의 교점에서 잘라 토막 낸 뒤, **다른 사각형 안에 들어간
* 토막을 버리고**, 남은 토막을 끝점끼리 이어 닫힌 고리로 만든다.
*/
outlineLoops(): PXY[][] {
return unionOutline(this.quads);
}
/**
* 행 횡단선(원점 c, 좌향 단위벡터 u — 편거리 o의 자리 = c + u·o)이 풋프린트와
* 겹치는 **그 측의 가장 바깥 편거리**. 안 겹치면 null.
*/
outerAt(
cx: number,
cy: number,
ux: number,
uy: number,
side: "left" | "right",
innerOffset: number,
/** 그 행 비탈 조각의 바깥 끝 편거리 — 조각 너머에서 시작하는 겹침은 무관하다. */
outerLimit: number,
): number | null {
const sign = side === "left" ? 1 : -1;
let outer: number | null = null;
for (const quad of this.quads) {
// 거친 걸러내기 — 행 원점에서 너무 먼 사각형은 볼 필요 없다.
if (
quad.minX > cx + NEAR_LIMIT_M ||
quad.maxX < cx - NEAR_LIMIT_M ||
quad.minY > cy + NEAR_LIMIT_M ||
quad.maxY < cy - NEAR_LIMIT_M
) {
continue;
}
// 사각형 변과 직선의 교차 — 볼록 사각형이라 교차 o들의 [min,max]가 곧 겹친 구간.
let hitMin = Infinity;
let hitMax = -Infinity;
const points = quad.points;
for (let i = 0; i < 4; i += 1) {
const a = points[i];
const b = points[(i + 1) % 4];
const ex = b.x - a.x;
const ey = b.y - a.y;
const denominator = ux * ey - uy * ex;
if (Math.abs(denominator) < 1e-12) continue;
const dx = a.x - cx;
const dy = a.y - cy;
const t = (ux * dy - uy * dx) / denominator;
if (t < -1e-9 || t > 1 + 1e-9) continue;
const o = Math.abs(ux) > Math.abs(uy) ? (a.x + ex * t - cx) / ux : (a.y + ey * t - cy) / uy;
if (o < hitMin) hitMin = o;
if (o > hitMax) hitMax = o;
}
if (hitMin > hitMax) continue;
// 그 측(노견 바깥)에 걸친 구간만 — 반대측 구조물은 무관하다.
const far = sign > 0 ? hitMax : hitMin;
const near = sign > 0 ? hitMin : hitMax;
if ((far - innerOffset) * sign <= MIN_REACH_M) continue;
// 조각 바깥 끝 너머에서 **시작**하는 겹침은 이 비탈과 무관하다(횡단선이 무한
// 직선이라 멀리 굽은 노선의 다른 구조물이 얻어걸린다 — 2026-08-25 프로브에서
// 84.3m 행이 275.7m 구조물에 걸려 -52m까지 잘려 나갔다).
if ((near - outerLimit) * sign > MIN_REACH_M) continue;
if (outer === null || (far - outer) * sign > 0) outer = far;
}
return outer;
}
}
/** 구조물 솔리드의 링 사이 평면 사각형을 풋프린트에 담는다(`kinds` 필터 선택). */
function addStructureQuads(
footprint: CarveFootprint,
structures: ReadonlyArray<CorridorStructure>,
kinds?: ReadonlySet<CorridorStructure["kind"]>,
): void {
for (const structure of structures) {
if (structure.kind === "pipe") continue;
if (kinds && !kinds.has(structure.kind)) continue;
const rings = structure.rings;
if (!rings || rings.length < 2) continue;
const perRing = structure.polygons;
const shared = structure.polygon;
const edges = rings.map((frame, index) => {
const edge = ringEdges(perRing?.[index] ?? shared ?? []);
if (!edge) return null;
return edge.map((offset) => ({
x: frame.cx + frame.leftX * offset,
y: frame.cy + frame.leftY * offset,
})) as [PXY, PXY];
});
// 링이 끊긴 자리(폴리곤 없음)에서 스트립을 나눈다 — 이어 붙이면 없는 자리까지 덮는다.
let run: Array<[PXY, PXY]> = [];
for (const edge of edges) {
if (edge) {
run.push(edge);
} else {
footprint.addStrip(run);
run = [];
}
}
footprint.addStrip(run);
}
}
/**
* 벽·날개벽(kind="revet")만의 풋프린트 — **패치 리본을 가르는** 부재들이다.
* 구체·바닥판(kind="basin")은 패치 아래에 눕는 부재라 여기서 빼야 한다(BOX 상판
* 위 성토선 패치가 통째로 지워진다).
*/
export function buildRevetFootprint(structures: ReadonlyArray<CorridorStructure>): CarveFootprint {
const footprint = new CarveFootprint();
addStructureQuads(footprint, structures, new Set(["revet"]));
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 = 끊김)을 **닫힌 외곽 고리**로 잇는다 — 안쪽을
* 앞으로, 바깥쪽을 뒤로 이어 닫는다. 내부 칸막이는 안 생긴다.
*/
export function assembleStripLoops(
rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>,
): Array<Array<[number, number, number]>> {
const loops: Array<Array<[number, number, number]>> = [];
let run: Array<[[number, number, number], [number, number, number]]> = [];
const flush = (): void => {
if (run.length >= 2) {
const loop: Array<[number, number, number]> = run.map((edge) => edge[0]);
for (let i = run.length - 1; i >= 0; i -= 1) loop.push(run[i][1]);
loop.push(loop[0]);
loops.push(loop);
}
run = [];
};
for (const edge of rows) {
if (edge) run.push(edge);
else flush();
}
flush();
return loops;
}
/**
* 구조물 **패치 리본**(2026-08-25 사용자 ④) — 구조물 때문에 기본 성토선에서 변형된
* 성토선은 투영으로 잘린 자리에 **다시 그려야 하는 대상**이다. B06 횡단 실루엣을
* 점유 구간 전 길이에 걸쳐 노선 프레임에 얹은 별도 리본으로 만든다. 기본 성토면은
* 건드리지 않는다(강제 변형 로직은 꺼져 있다) — 패치가 잘린 구멍을 채운다.
*
* 벽·날개벽(kind="revet")이 패치 영역을 가로지르면 행마다 그 풋프린트만큼 안쪽을
* 잘라내고, 한 행이 통째로 덮이면 리본을 거기서 끊는다 — **분리된 서피스는 별개
* 리본**이 된다(2026-08-25 사용자).
*/
export function buildStructurePatchRibbons(
crossSections: ReadonlyArray<CrossSection>,
structures: ReadonlyArray<CorridorStructure>,
frameAt: (chainageM: number) => RouteFrame | null,
profileZ: ((chainageM: number) => number | null) | null,
): CorridorRibbon[] {
const ribbons: CorridorRibbon[] = [];
const revets = buildRevetFootprint(structures);
for (const section of crossSections) {
const design = section.design;
for (const silhouette of structureSilhouettes(section)) {
// 패치 전용 점(세월교 성토부선)이 있으면 그걸 쓴다 — 벽 전면·유로를 리본으로
// 덮으면 관 구멍·L자 통로가 막힌다(2026-08-25 사용자 ②).
let source = silhouette.patchPoints ?? silhouette.points;
// 노견 확장점 안쪽(수평 구간)은 노견 조각이 가져갔다 — 패치는 그 바깥부터.
// 단 그 측에 측구가 있으면 노견을 안 넓혔으므로 스트립도 하지 않는다 — 하면
// 패치가 노견에서 떨어져 뜬다(2026-08-25 사용자 보고).
const ditch = design?.ditch;
const ditchEnabled = design?.ditch_enabled ?? (ditch != null && ditch.type !== "none");
const ditchOnSide = ditchEnabled && design?.ditch_side === silhouette.side;
const widen = silhouette.widenTo;
if (widen && !silhouette.patchPoints && !ditchOnSide) {
const sign = silhouette.side === "left" ? 1 : -1;
source = source.filter((point) => (point.offset_m - widen.offset_m) * sign > -1e-9);
if (source.length && Math.abs(source[0].offset_m - widen.offset_m) > 1e-9) {
source = [widen, ...source];
}
}
if (source.length < 2) continue;
// "auto" = 실루엣 가운데 표고가 지반보다 높으면 성토, 낮으면 절토.
let kind: CorridorKind = silhouette.kind === "auto" ? "fill" : silhouette.kind;
if (silhouette.kind === "auto") {
const groundAt = groundInterpolator(section.samples);
const mid = silhouette.points[Math.floor(silhouette.points.length / 2)];
if (groundAt && mid.elevation_m < groundAt(mid.offset_m) - 0.05) kind = "cut";
}
const colCount = PIECE_COLS[kind];
const from = section.chainage_m - silhouette.span.beforeM;
const to = section.chainage_m + silhouette.span.afterM;
if (!(to - from > 1e-6)) continue;
const rowChainages: number[] = [];
for (let at = from; at < to; at += 1) rowChainages.push(at);
rowChainages.push(to);
const baseZ = profileZ?.(section.chainage_m) ?? null;
// 행 버퍼 — 벽·날개벽에 통째로 덮인 행에서 끊어 별개 리본으로 내보낸다.
let buffer: Array<{ chainage: number; frame: RouteFrame; points: typeof source }> = [];
const flush = (): void => {
if (buffer.length >= 2) {
const positions = new Float64Array(buffer.length * colCount * 3);
buffer.forEach((row, index) => {
const shift =
baseZ != null && row.frame.designZ != null ? row.frame.designZ - baseZ : 0;
resample(row.points, colCount).forEach((point, col) => {
const i = (index * colCount + col) * 3;
positions[i] = row.frame.cx + row.frame.leftX * point.offset_m;
positions[i + 1] = row.frame.cy + row.frame.leftY * point.offset_m;
positions[i + 2] = point.elevation_m + shift;
});
});
ribbons.push({
kind,
side: silhouette.side,
colCount,
chainages: buffer.map((row) => row.chainage),
positions,
patch: true,
});
}
buffer = [];
};
for (const at of rowChainages) {
const frame = frameAt(at);
if (!frame) {
flush();
continue;
}
let points = source;
if (!revets.isEmpty) {
const outerCov = revets.outerAt(
frame.cx,
frame.cy,
frame.leftX,
frame.leftY,
silhouette.side,
points[0].offset_m,
points[points.length - 1].offset_m,
);
if (outerCov !== null) {
const cut = cutInnerTo(points, outerCov, silhouette.side);
if (!cut) {
// 이 행은 벽·날개벽이 통째로 덮는다 — 리본을 끊는다(분리 서피스).
flush();
continue;
}
points = cut;
}
}
buffer.push({ chainage: at, frame, points });
}
flush();
}
}
return ribbons;
}
+160 -272
View File
@@ -1,258 +1,115 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Clip.ts
* 원지반 서피스에서 코리도(절·성토 점유) 영역을 **경계 재절단**으로 도려낸다.
* 서피스에서 **평면 영역**(`_Corridor_Region.ts`)이 덮는 자리를 경계 재절단으로
* 도려낸다. 두 군데에 쓴다.
* · 지형 ← 코리도 스트립(절·성토 점유)만. 리본 절단은 Build의 파라메트릭 트림으로 옮겼다(2026-08-25)
*
* 방식(2026-08-23 사용자 확정 — 정밀): 코리도 외곽(좌우 catch line)을 씬
* 수평면(x,z)에 투영한 스트립으로 지형 삼각형을 판정하고,
* 방식(2026-08-23 사용자 확정 — 정밀): 영역 경계를 씬 수평면(x,z)에 투영한 선분으로
* 삼각형을 판정하고,
* - 완전 내부 삼각형은 제거,
* - 경계에 걸친 삼각형은 catch line 선분으로 실제 절단·재삼각분할 후
* 내부 조각만 버린다.
* 원본 geometry는 건드리지 않고 클리핑본을 새로 만든다 — [예상형상] 토글
* OFF 시 원본 완전체가 다시 보여야 하기 때문(두 벌 유지·스왑).
* - 경계에 걸친 삼각형은 경계 선분으로 실제 절단·재삼각분할 후 내부 조각만 버린다.
* 원본 geometry는 건드리지 않고 클리핑본을 새로 만든다 — [예상형상] 토글 OFF 시
* 원본 완전체가 다시 보여야 하기 때문(두 벌 유지·스왑).
*
* 스트립 내부 판정은 프레임 셀(사각형) 단위라 급곡선에서 외곽 폴리곤이
* 자기교차해도 견고하다. 정점색(color) 속성은 절단 시 선형 보간해 유지한다.
* 절단면(**밀어내기 서피스**)은 따로 만들지 않는다 — 잘린 자리에 구조물 솔리드가
* 그대로 들어앉아 빈틈이 메워진다(2026-08-25 사용자 확정 절차 ④~⑥). 절단 경계가
* 곧 구조물 외곽이라 각은 지지만 서피스를 뭉개지 않는다.
*
* 정점 속성(색·UV 등)은 절단 시 선형 보간해 유지한다 — 리본은 UV로 빗금을 반복하므로
* UV가 끊기면 절단 자리에서 해칭이 튄다.
* ========================================================================== */
import * as THREE from "three";
import type { ModelBounds } from "./B05_Profile_UI_Markers";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
import type { P2, PlanRegion, RegionSegment } from "./B05_Profile_UI_Corridor_Region";
import { CorridorStrip, pointInTri2, segmentsIntersect } from "./B05_Profile_UI_Corridor_Region";
interface P2 {
x: number;
z: number;
export { CorridorStrip };
/** 보간해서 이어 갈 정점 속성 한 종류. */
interface AttrSlot {
name: string;
itemSize: number;
source: THREE.BufferAttribute;
values: ArrayLike<number>;
extra: number[];
}
/** 절단 대상 삼각형 — 씬 좌표 정점 3개와 (있으면) 정점색. */
/** 절단 대상 삼각형 — 씬 좌표 정점 3개와 정점 속성(슬롯 순서). */
interface Tri {
px: Float64Array; // [x0,y0,z0, x1,y1,z1, x2,y2,z2]
color: Float64Array | null; // [r0,g0,b0, ...]
attrs: Float64Array[]; // 슬롯마다 [v0…, v1…, v2…]
}
const EPS = 1e-9;
/** 절단 대상에서 빼는 속성 — 위치는 따로 다루고, 법선은 원본 것만 물려받는다. */
const SKIPPED_ATTRIBUTES = new Set(["position", "normal"]);
function cross2(ax: number, az: number, bx: number, bz: number): number {
return ax * bz - az * bx;
/**
* 여러 영역을 하나처럼 본다 — 어느 하나에라도 들면 "안"이다.
* `sceneY`를 주면 그 표고를 덮는 영역만 센다(구조물 밑을 지나는 면은 안 지운다).
*/
function insideAny(regions: ReadonlyArray<PlanRegion>, p: P2, sceneY?: number): boolean {
return regions.some((region) => region.contains(p, sceneY));
}
function pointInTri2(p: P2, a: P2, b: P2, c: P2): boolean {
const d1 = cross2(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z);
const d2 = cross2(c.x - b.x, c.z - b.z, p.x - b.x, p.z - b.z);
const d3 = cross2(a.x - c.x, a.z - c.z, p.x - c.x, p.z - c.z);
const hasNeg = d1 < -EPS || d2 < -EPS || d3 < -EPS;
const hasPos = d1 > EPS || d2 > EPS || d3 > EPS;
return !(hasNeg && hasPos);
}
function segmentsIntersect(a: P2, b: P2, c: P2, d: P2): boolean {
const d1 = cross2(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z);
const d2 = cross2(b.x - a.x, b.z - a.z, d.x - a.x, d.z - a.z);
const d3 = cross2(d.x - c.x, d.z - c.z, a.x - c.x, a.z - c.z);
const d4 = cross2(d.x - c.x, d.z - c.z, b.x - c.x, b.z - c.z);
return d1 * d2 < -EPS && d3 * d4 < -EPS;
}
/** 코리도 스트립 — 셀(프레임 사각형) 격자와 경계 선분 목록. */
export class CorridorStrip {
private cells: Array<{
a: P2;
b: P2;
c: P2;
d: P2;
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}> = [];
private segments: Array<{
p: P2;
q: P2;
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}> = [];
readonly minX: number;
readonly maxX: number;
readonly minZ: number;
readonly maxZ: number;
/** 균일격자 공간색인 — 셀·선분을 통째로 훑으면 지형 삼각형 수 × 셀 수가 되어
* 편집 반복 시 초 단위로 멎는다(2026-08-23 프리즈 보고). 격자로 후보만 본다. */
private grid = 1;
private gridCols = 1;
private gridRows = 1;
private cellBuckets: number[][] = [];
private segmentBuckets: number[][] = [];
constructor(build: CorridorBuildResult, bounds: ModelBounds) {
const cx = (bounds.x[0] + bounds.x[1]) / 2;
const cy = (bounds.y[0] + bounds.y[1]) / 2;
const toScene = ([mx, my]: [number, number]): P2 => ({ x: mx - cx, z: -(my - cy) });
const left = build.outline.left.map(toScene);
const right = build.outline.right.map(toScene);
const count = Math.min(left.length, right.length);
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
const touch = (p: P2): void => {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.z < minZ) minZ = p.z;
if (p.z > maxZ) maxZ = p.z;
};
for (let i = 0; i < count - 1; i += 1) {
const a = left[i];
const b = right[i];
const c = right[i + 1];
const d = left[i + 1];
[a, b, c, d].forEach(touch);
this.cells.push({
a,
b,
c,
d,
minX: Math.min(a.x, b.x, c.x, d.x),
maxX: Math.max(a.x, b.x, c.x, d.x),
minZ: Math.min(a.z, b.z, c.z, d.z),
maxZ: Math.max(a.z, b.z, c.z, d.z),
});
}
const addSegment = (p: P2, q: P2): void => {
this.segments.push({
p,
q,
minX: Math.min(p.x, q.x),
maxX: Math.max(p.x, q.x),
minZ: Math.min(p.z, q.z),
maxZ: Math.max(p.z, q.z),
});
};
for (let i = 0; i < count - 1; i += 1) {
addSegment(left[i], left[i + 1]);
addSegment(right[i], right[i + 1]);
}
if (count > 0) {
addSegment(left[0], right[0]);
addSegment(left[count - 1], right[count - 1]);
}
this.minX = minX;
this.maxX = maxX;
this.minZ = minZ;
this.maxZ = maxZ;
this.buildIndex();
}
/** 셀·선분을 균일격자 버킷에 넣는다(격자 한 칸 ≈ 코리도 폭). */
private buildIndex(): void {
if (!this.cells.length) return;
const meanCell =
this.cells.reduce((sum, cell) => sum + (cell.maxX - cell.minX) + (cell.maxZ - cell.minZ), 0) /
this.cells.length;
this.grid = Math.max(2, meanCell);
this.gridCols = Math.max(1, Math.ceil((this.maxX - this.minX) / this.grid) + 1);
this.gridRows = Math.max(1, Math.ceil((this.maxZ - this.minZ) / this.grid) + 1);
this.cellBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []);
this.segmentBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []);
const put = (
buckets: number[][],
index: number,
minX: number,
maxX: number,
minZ: number,
maxZ: number,
): void => {
const c0 = this.colOf(minX);
const c1 = this.colOf(maxX);
const r0 = this.rowOf(minZ);
const r1 = this.rowOf(maxZ);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) buckets[r * this.gridCols + c].push(index);
}
};
this.cells.forEach((cell, i) =>
put(this.cellBuckets, i, cell.minX, cell.maxX, cell.minZ, cell.maxZ),
);
this.segments.forEach((s, i) => put(this.segmentBuckets, i, s.minX, s.maxX, s.minZ, s.maxZ));
}
private colOf(x: number): number {
return Math.min(this.gridCols - 1, Math.max(0, Math.floor((x - this.minX) / this.grid)));
}
private rowOf(z: number): number {
return Math.min(this.gridRows - 1, Math.max(0, Math.floor((z - this.minZ) / this.grid)));
}
contains(p: P2): boolean {
if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false;
const bucket = this.cellBuckets[this.rowOf(p.z) * this.gridCols + this.colOf(p.x)];
if (!bucket) return false;
for (const index of bucket) {
const cell = this.cells[index];
if (p.x < cell.minX || p.x > cell.maxX || p.z < cell.minZ || p.z > cell.maxZ) continue;
if (pointInTri2(p, cell.a, cell.b, cell.c) || pointInTri2(p, cell.a, cell.c, cell.d)) {
return true;
}
}
return false;
}
/** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보(격자 후보만 검사). */
segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number) {
if (!this.segmentBuckets.length) return [];
const seen = new Set<number>();
const result: (typeof this.segments)[number][] = [];
const c0 = this.colOf(minX);
const c1 = this.colOf(maxX);
const r0 = this.rowOf(minZ);
const r1 = this.rowOf(maxZ);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
for (const index of this.segmentBuckets[r * this.gridCols + c]) {
if (seen.has(index)) continue;
seen.add(index);
const s = this.segments[index];
if (s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ) result.push(s);
}
}
}
return result;
}
function segmentsNearAny(
regions: ReadonlyArray<PlanRegion>,
minX: number,
maxX: number,
minZ: number,
maxZ: number,
): RegionSegment[] {
if (regions.length === 1) return regions[0].segmentsNear(minX, maxX, minZ, maxZ);
const result: RegionSegment[] = [];
for (const region of regions) result.push(...region.segmentsNear(minX, maxX, minZ, maxZ));
return result;
}
/** 삼각형을 선분의 무한직선으로 절단해 소삼각형 목록으로 — 실교차 시에만 호출. */
function splitTriByLine(tri: Tri, p: P2, q: P2): Tri[] {
const dir = { x: q.x - p.x, z: q.z - p.z };
const dist = (x: number, z: number): number => cross2(dir.x, dir.z, x - p.x, z - p.z);
const dist = (x: number, z: number): number => dir.x * (z - p.z) - dir.z * (x - p.x);
const d = [dist(tri.px[0], tri.px[2]), dist(tri.px[3], tri.px[5]), dist(tri.px[6], tri.px[8])];
const pos: number[] = [];
const neg: number[] = [];
d.forEach((value, i) => (value >= 0 ? pos.push(i) : neg.push(i)));
if (pos.length === 0 || neg.length === 0) return [tri];
// 정점 i·j 사이 직선 교차점의 보간 파라미터.
const lerpVertex = (i: number, j: number): { p: number[]; c: number[] | null } => {
const t = d[i] / (d[i] - d[j]);
const point = [0, 1, 2].map(
(axis) => tri.px[i * 3 + axis] + (tri.px[j * 3 + axis] - tri.px[i * 3 + axis]) * t,
);
const color = tri.color
? [0, 1, 2].map(
(axis) =>
tri.color![i * 3 + axis] + (tri.color![j * 3 + axis] - tri.color![i * 3 + axis]) * t,
)
: null;
return { p: point, c: color };
};
const vertexOf = (i: number): { p: number[]; c: number[] | null } => ({
/** 정점 하나의 값 묶음 — 위치 3개 + 슬롯별 값. */
interface Vertex {
p: number[];
a: number[][];
}
const vertexOf = (i: number): Vertex => ({
p: [tri.px[i * 3], tri.px[i * 3 + 1], tri.px[i * 3 + 2]],
c: tri.color ? [tri.color[i * 3], tri.color[i * 3 + 1], tri.color[i * 3 + 2]] : null,
a: tri.attrs.map((values, slot) => {
const size = values.length / 3;
void slot;
return Array.from({ length: size }, (_unused, c) => values[i * size + c]);
}),
});
const makeTri = (a: { p: number[]; c: number[] | null }, b: typeof a, c: typeof a): Tri => ({
const lerpVertex = (i: number, j: number): Vertex => {
const t = d[i] / (d[i] - d[j]);
return {
p: [0, 1, 2].map(
(axis) => tri.px[i * 3 + axis] + (tri.px[j * 3 + axis] - tri.px[i * 3 + axis]) * t,
),
a: tri.attrs.map((values) => {
const size = values.length / 3;
return Array.from(
{ length: size },
(_unused, c) => values[i * size + c] + (values[j * size + c] - values[i * size + c]) * t,
);
}),
};
};
const makeTri = (a: Vertex, b: Vertex, c: Vertex): Tri => ({
px: Float64Array.from([...a.p, ...b.p, ...c.p]),
color: a.c && b.c && c.c ? Float64Array.from([...a.c, ...b.c, ...c.c]) : null,
attrs: a.a.map((_unused, slot) =>
Float64Array.from([...a.a[slot], ...b.a[slot], ...c.a[slot]]),
),
});
// 한쪽 1개 / 반대쪽 2개 — 교차점 2개로 삼각형 3개.
@@ -274,6 +131,11 @@ function triCentroid(tri: Tri): P2 {
};
}
/** 삼각형 무게중심의 씬 Y — 구조물 상단과 견주어 덮이는지 판정한다. */
function triCentroidY(tri: Tri): number {
return (tri.px[1] + tri.px[4] + tri.px[7]) / 3;
}
/** 세그먼트가 삼각형과 실제로 교차하는가(끝점 포함) — 과절단 방지 가드. */
function segmentTouchesTri(tri: Tri, p: P2, q: P2): boolean {
const a: P2 = { x: tri.px[0], z: tri.px[2] };
@@ -285,46 +147,54 @@ function segmentTouchesTri(tri: Tri, p: P2, q: P2): boolean {
);
}
/** 보간해 이어 갈 속성 슬롯 목록 — 위치·법선을 뺀 나머지 전부. */
function attributeSlots(geometry: THREE.BufferGeometry): AttrSlot[] {
const slots: AttrSlot[] = [];
for (const name of Object.keys(geometry.attributes)) {
if (SKIPPED_ATTRIBUTES.has(name)) continue;
const attribute = geometry.getAttribute(name) as THREE.BufferAttribute | undefined;
if (!attribute) continue;
// 배열은 count*itemSize보다 길 수 있다(로더가 버퍼를 공유·패딩하는 경우) — 정확히
// 쓰는 구간만 잘라 쓴다. 통째로 복사하면 대상 배열을 넘어 터진다
// (2026-08-23 "offset is out of bounds"로 클리핑 전체가 죽어 있던 원인).
const values = (
attribute.array as unknown as { subarray(a: number, b: number): ArrayLike<number> }
).subarray(0, attribute.count * attribute.itemSize);
slots.push({ name, itemSize: attribute.itemSize, source: attribute, values, extra: [] });
}
return slots;
}
/**
* 지형 BufferGeometry 하나를 스트립으로 재절단한다.
* 반환: 스트립 밖 조각만 남긴 non-indexed geometry (변화 없으면 null).
* BufferGeometry 하나를 영역들로 재절단한다.
* 반환: 영역 밖 조각만 남긴 geometry (변화 없으면 null).
*/
export function clipGeometry(
geometry: THREE.BufferGeometry,
strip: CorridorStrip,
regions: ReadonlyArray<PlanRegion>,
): THREE.BufferGeometry | null {
if (!regions.length) return null;
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
if (!position) return null;
const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
const index = geometry.getIndex();
const triCount = index ? index.count / 3 : position.count / 3;
// 성능(2026-08-23 프리즈 개선): 원본 정점 버퍼는 그대로 두고 **인덱스만** 새로 만든다.
// 코리도는 지형 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를
// 영역은 서피스 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를
// 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만
// 새 정점으로 뒤에 덧붙인다.
// 버퍼는 count*3보다 길 수 있다(로더가 버퍼를 공유·패딩하는 경우) — 정확히
// 쓰는 구간만 잘라 쓴다. 통째로 복사하면 대상 배열을 넘어 터진다
// (2026-08-23 "offset is out of bounds"로 클리핑 전체가 죽어 있던 원인).
const px = (position.array as Float32Array).subarray(0, position.count * 3);
// 정점색은 RGB(3)일 수도 RGBA(4)일 수도 있다 — trimesh GLB는 RGBA로 내보낸다.
// stride를 고정하면 색이 어긋나 지형이 새까맣게 보인다(2026-08-23 화면 검증).
const colorSize = color?.itemSize ?? 3;
// 정점색 배열은 정규화 Uint8일 수 있다(GLB 관례) — **원본 배열 그대로** 복사하고
// normalized 플래그까지 물려받아야 색이 그대로 산다. Float32로 옮겨 담으면
// 0~255 raw가 그대로 들어가 색이 날아간다(2026-08-23 화면 검증).
const cx = color
? (color.array as unknown as { subarray(a: number, b: number): ArrayLike<number> }).subarray(
0,
color.count * colorSize,
)
: null;
const slots = attributeSlots(geometry);
const keptIndices: number[] = [];
const extraPositions: number[] = [];
const extraColors: number[] = [];
const baseCount = position.count;
let changed = false;
const regionMinX = Math.min(...regions.map((region) => region.minX));
const regionMaxX = Math.max(...regions.map((region) => region.maxX));
const regionMinZ = Math.min(...regions.map((region) => region.minZ));
const regionMaxZ = Math.max(...regions.map((region) => region.maxZ));
const readTri = (ia: number, ib: number, ic: number): Tri => ({
px: Float64Array.from([
px[ia * 3],
@@ -337,22 +207,24 @@ export function clipGeometry(
px[ic * 3 + 1],
px[ic * 3 + 2],
]),
color: cx
? Float64Array.from(
[ia, ib, ic].flatMap((v) =>
Array.from({ length: colorSize }, (_unused, c) => cx[v * colorSize + c]),
),
)
: null,
attrs: slots.map((slot) =>
Float64Array.from(
[ia, ib, ic].flatMap((v) =>
Array.from({ length: slot.itemSize }, (_unused, c) => slot.values[v * slot.itemSize + c]),
),
),
),
});
/** 잘린 조각 — 새 정점으로 덧붙이고 그 인덱스를 쓴다. */
const emitFragment = (tri: Tri): void => {
for (let v = 0; v < 3; v += 1) {
keptIndices.push(baseCount + extraPositions.length / 3);
extraPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]);
if (cx) {
for (let c = 0; c < colorSize; c += 1) extraColors.push(tri.color![v * colorSize + c]);
}
slots.forEach((slot, s) => {
for (let c = 0; c < slot.itemSize; c += 1) {
slot.extra.push(tri.attrs[s][v * slot.itemSize + c]);
}
});
}
};
@@ -370,16 +242,17 @@ export function clipGeometry(
const maxX = Math.max(ax, bx, ccx);
const minZ = Math.min(az, bz, ccz);
const maxZ = Math.max(az, bz, ccz);
// 코리도 전체 AABB 밖 — 그대로 유지(정점 복사 없이 인덱스만).
if (maxX < strip.minX || minX > strip.maxX || maxZ < strip.minZ || minZ > strip.maxZ) {
// 영역 전체 AABB 밖 — 그대로 유지(정점 복사 없이 인덱스만).
if (maxX < regionMinX || minX > regionMaxX || maxZ < regionMinZ || minZ > regionMaxZ) {
keptIndices.push(ia, ib, ic);
continue;
}
const near = strip.segmentsNear(minX, maxX, minZ, maxZ);
const near = segmentsNearAny(regions, minX, maxX, minZ, maxZ);
if (near.length === 0) {
// 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는
// 삼각형은 안/밖 어느 한쪽에 통째로 있다).
if (strip.contains({ x: (ax + bx + ccx) / 3, z: (az + bz + ccz) / 3 })) {
const cy = (px[ia * 3 + 1] + px[ib * 3 + 1] + px[ic * 3 + 1]) / 3;
if (insideAny(regions, { x: (ax + bx + ccx) / 3, z: (az + bz + ccz) / 3 }, cy)) {
changed = true;
continue;
}
@@ -400,7 +273,9 @@ export function clipGeometry(
}
fragments = next;
}
const kept = fragments.filter((fragment) => !strip.contains(triCentroid(fragment)));
const kept = fragments.filter(
(fragment) => !insideAny(regions, triCentroid(fragment), triCentroidY(fragment)),
);
if (kept.length === fragments.length) {
keptIndices.push(ia, ib, ic); // 절단은 됐지만 전부 밖 — 원본 인덱스 유지.
} else {
@@ -415,20 +290,27 @@ export function clipGeometry(
positions.set(px, 0);
positions.set(extraPositions, baseCount * 3);
clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3));
if (color && cx) {
const source = color.array as unknown as {
for (const slot of slots) {
// 정점색은 정규화 Uint8일 수 있다(GLB 관례) — **원본 배열 종류 그대로** 담고
// normalized 플래그까지 물려받아야 색이 그대로 산다. Float32로 옮겨 담으면
// 0~255 raw가 그대로 들어가 색이 날아간다(2026-08-23 화면 검증).
const source = slot.source.array as unknown as {
constructor: new (length: number) => { set(a: ArrayLike<number>, o?: number): void };
};
const colors = new source.constructor(baseCount * colorSize + extraColors.length);
colors.set(cx, 0);
// 정수 배열이면 보간 소수부가 잘린다 — 반올림해 담는다.
colors.set(
color.normalized ? extraColors.map((value) => Math.round(value)) : extraColors,
baseCount * colorSize,
const merged = new source.constructor(baseCount * slot.itemSize + slot.extra.length);
merged.set(slot.values, 0);
// 정수 배열이면 보간 소수부가 잘린다 — 반올림해 담는다.
merged.set(
slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra,
baseCount * slot.itemSize,
);
clipped.setAttribute(
"color",
new THREE.BufferAttribute(colors as unknown as THREE.TypedArray, colorSize, color.normalized),
slot.name,
new THREE.BufferAttribute(
merged as unknown as THREE.TypedArray,
slot.itemSize,
slot.source.normalized,
),
);
}
clipped.setIndex(keptIndices);
@@ -449,12 +331,15 @@ export function clipGeometry(
return clipped;
}
/** 포인트클라우드(meshfree) — 스트립 안 점만 걷어낸다. */
function clipPoints(geometry: THREE.BufferGeometry, strip: CorridorStrip): THREE.BufferGeometry {
/** 포인트클라우드(meshfree) — 영역 안 점만 걷어낸다. */
function clipPoints(
geometry: THREE.BufferGeometry,
regions: ReadonlyArray<PlanRegion>,
): THREE.BufferGeometry {
const position = geometry.getAttribute("position") as THREE.BufferAttribute;
const keep: number[] = [];
for (let i = 0; i < position.count; i += 1) {
if (!strip.contains({ x: position.getX(i), z: position.getZ(i) })) keep.push(i);
if (!insideAny(regions, { x: position.getX(i), z: position.getZ(i) })) keep.push(i);
}
const positions = new Float32Array(keep.length * 3);
keep.forEach((src, dst) => {
@@ -481,6 +366,9 @@ function clipPoints(geometry: THREE.BufferGeometry, strip: CorridorStrip): THREE
/**
* 코리도 스트립으로 도려낸 지형을 만든다 — 원본은 불변.
*
* **구조물 투영커브는 여기 쓰지 않는다**(2026-08-25 사용자: 투영 및 삭제는 원지반 제외).
* 원지반은 코리도 점유분만 걷어내고, 구조물이 덮는 자리는 성토·절토 리본에서만 도려낸다.
*
* 분할본(TerrainBandSplit)을 받으면 **노선 주변(near)만** 재트림하고 원거리(far)는
* 처음 만든 그대로 다시 쓴다(2026-08-23 사용자 제안). 편집마다 지형 전체를
* 훑던 비용이 밴드 안쪽으로 줄어든다.
@@ -490,7 +378,7 @@ export function clipTerrain(
build: CorridorBuildResult,
bounds: ModelBounds,
): THREE.Object3D {
const strip = new CorridorStrip(build, bounds);
const regions: PlanRegion[] = [new CorridorStrip(build, bounds)];
const root = new THREE.Group();
root.name = "terrain-clipped";
// geometry·material 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다.
@@ -498,14 +386,14 @@ export function clipTerrain(
split.near.forEach((part) => {
if (part.points) {
const points = new THREE.Points(
clipPoints(part.geometry, strip),
clipPoints(part.geometry, regions),
part.material as THREE.Material,
);
points.userData.owned = true;
root.add(points);
return;
}
const clipped = clipGeometry(part.geometry, strip);
const clipped = clipGeometry(part.geometry, regions);
const mesh = new THREE.Mesh(clipped ?? part.geometry, part.material);
mesh.userData.owned = clipped !== null;
root.add(mesh);
+141 -2
View File
@@ -10,7 +10,8 @@
* 테이퍼가 생긴다. 접속 길이 기준 = **인접 측점까지**(2026-08-24 사용자 확정).
* ========================================================================== */
import type { OffsetPoint, StationPieces } from "./B05_Profile_UI_Corridor_Station";
import type { OffsetPoint, StationPieces, XY } from "./B05_Profile_UI_Corridor_Station";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
/** 제어점 하나 — 그 누가거리에서 쓸 단면과 출처. */
export interface PieceControl {
@@ -31,10 +32,13 @@ export function buildPieceControls(
stations: StationPieces[],
side: "left" | "right",
pick: (station: StationPieces) => OffsetPoint[] | undefined,
/** 구간 출처 — 기본은 wallSpans. 노견은 shoulderSpans(노견 확장)도 본다(2026-08-25). */
spanOf: (station: StationPieces) => { beforeM: number; afterM: number } | undefined = (station) =>
station.wallSpans?.[side],
): PieceControl[] | null {
const spans = stations
.map((station) => {
const span = station.wallSpans?.[side];
const span = spanOf(station);
return span
? {
fromM: station.chainage_m - span.beforeM,
@@ -96,3 +100,138 @@ export function bracketControls(
const last = controls[controls.length - 1];
return { a: last, b: last, t: 0 };
}
/**
* Centripetal Catmull-Rom 한 구간(p1→p2). 제어점을 지나면서 오버슈트·자기교차가
* 없는 성질이라 급커브(실측 꺾임각 최대 42°)에도 안전하다.
*/
function catmullRom(p0: XY, p1: XY, p2: XY, p3: XY, t: number): XY {
const ALPHA = 0.5; // centripetal
const knot = (previous: number, a: XY, b: XY): number =>
previous + Math.max(1e-6, Math.pow(Math.hypot(b.x - a.x, b.y - a.y), ALPHA));
const t0 = 0;
const t1 = knot(t0, p0, p1);
const t2 = knot(t1, p1, p2);
const t3 = knot(t2, p2, p3);
const time = t1 + (t2 - t1) * t;
const mix = (a: XY, b: XY, ta: number, tb: number): XY => {
const span = tb - ta || 1e-9;
const w = (tb - time) / span;
return { x: a.x * w + b.x * (1 - w), y: a.y * w + b.y * (1 - w) };
};
const a1 = mix(p0, p1, t0, t1);
const a2 = mix(p1, p2, t1, t2);
const a3 = mix(p2, p3, t2, t3);
const b1 = mix(a1, a2, t0, t2);
const b2 = mix(a2, a3, t1, t3);
return mix(b1, b2, t1, t2);
}
/**
* 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 돌려준다.
*
* 정점 사이를 직선으로 이으면 정점 간격(실측 평균 2.6m)만큼 각진다 — 0.1m로 잘게
* 쪼개도 그 각짐은 그대로다(2026-08-23 사용자 지적). 그래서 **스플라인**으로 잇고
* 세분 간격은 0.2m로 되돌린다: 데이터는 절반, 곡선은 훨씬 매끄럽다.
*/
export function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: number) => XY) | null {
const points = routePoints.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
if (points.length < 2) return null;
const cumulative: number[] = [0];
for (let i = 1; i < points.length; i += 1) {
cumulative.push(
cumulative[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y),
);
}
const at = (index: number): XY => {
const clamped = Math.min(points.length - 1, Math.max(0, index));
return { x: points[clamped].x, y: points[clamped].y };
};
return (chainage: number): XY => {
const target = Math.min(Math.max(chainage, 0), cumulative[cumulative.length - 1]);
let index = 1;
while (index < points.length - 1 && cumulative[index] < target) index += 1;
const span = cumulative[index] - cumulative[index - 1];
const t = span <= 1e-12 ? 0 : (target - cumulative[index - 1]) / span;
// 양 끝 구간은 바깥 제어점이 없다 — 끝점을 겹쳐 써서 접선이 튀지 않게 한다.
return catmullRom(at(index - 2), at(index - 1), at(index), at(index + 1), t);
};
}
/** 좌측 단위벡터 각도 보간(최단각) — 측점 프레임과 일관된 중간 프레임 방향. */
export function slerpLeft(a: XY, b: XY, t: number): XY {
const angleA = Math.atan2(a.y, a.x);
let delta = Math.atan2(b.y, b.x) - angleA;
if (delta > Math.PI) delta -= Math.PI * 2;
if (delta < -Math.PI) delta += Math.PI * 2;
const angle = angleA + delta * t;
return { x: Math.cos(angle), y: Math.sin(angle) };
}
export function lerpPoints(a: OffsetPoint[], b: OffsetPoint[], t: number): OffsetPoint[] {
return a.map((p, i) => ({
offset_m: p.offset_m + (b[i].offset_m - p.offset_m) * t,
elevation_m: p.elevation_m + (b[i].elevation_m - p.elevation_m) * t,
}));
}
/** 종단 계획선 표고 보간기 — chainage로 계획고를 되짚는다(종단곡선 포함). */
export function buildProfileSampler(
samples?: Array<{ chainage_m: number; elevation_m: number }>,
): ((chainage: number) => number | null) | null {
if (!samples || samples.length < 2) return null;
const sorted = [...samples].sort((a, b) => a.chainage_m - b.chainage_m);
return (chainage: number): number | null => {
if (chainage <= sorted[0].chainage_m) return sorted[0].elevation_m;
for (let i = 1; i < sorted.length; i += 1) {
if (chainage <= sorted[i].chainage_m) {
const span = sorted[i].chainage_m - sorted[i - 1].chainage_m;
const t = span <= 1e-12 ? 0 : (chainage - sorted[i - 1].chainage_m) / span;
return sorted[i - 1].elevation_m + (sorted[i].elevation_m - sorted[i - 1].elevation_m) * t;
}
}
return sorted[sorted.length - 1].elevation_m;
};
}
/**
* 계획고와 지반고가 만나는 자리(절토↔성토 전환점)를 찾는 함수를 만든다.
*
* 두 측점 사이 어딘가에서 계획선이 지면선을 통과한다 — 그 자리가 측구·절토의
* 종점이고 거기서부터 성토가 시작된다(2026-08-23 사용자 확정). 종단 계획선 샘플의
* `계획고 - 지반고` 부호가 뒤집히는 구간을 선형보간해 정확한 누가거리를 낸다.
*/
export function buildCrossingFinder(
samples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>,
): ((fromM: number, toM: number) => number | null) | null {
const usable = (samples ?? [])
.filter((sample) => typeof sample.ground_elevation_m === "number")
.map((sample) => ({
chainage_m: sample.chainage_m,
difference: sample.elevation_m - (sample.ground_elevation_m as number),
}))
.sort((a, b) => a.chainage_m - b.chainage_m);
if (usable.length < 2) return null;
return (fromM: number, toM: number): number | null => {
const lo = Math.min(fromM, toM);
const hi = Math.max(fromM, toM);
for (let i = 1; i < usable.length; i += 1) {
const a = usable[i - 1];
const b = usable[i];
if (b.chainage_m <= lo || a.chainage_m >= hi) continue;
// 계획고와 지반고가 정확히 같은 샘플 — 구간 **안쪽**일 때만 전환점이다.
// BP·EP는 정의상 지반과 만나므로, 이걸 거르지 않으면 구간 시작점이 전환점으로
// 잡혀 측구·절토가 0.4m 만에 끝났다(2026-08-23 실측).
if (a.difference === 0) {
if (a.chainage_m > lo && a.chainage_m < hi) return a.chainage_m;
continue;
}
if (a.difference * b.difference >= 0) continue;
const span = b.chainage_m - a.chainage_m;
const ratio = span <= 1e-12 ? 0 : a.difference / (a.difference - b.difference);
const crossing = a.chainage_m + span * ratio;
if (crossing > lo && crossing < hi) return crossing;
}
return null;
};
}
+94 -4
View File
@@ -34,6 +34,21 @@ 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,
};
/** 마구리(시·종점 절단면) 색 — 절단면임이 드러나게 어둡게. */
const CAP_COLOR = 0x2a2f3a;
@@ -141,14 +156,17 @@ function ribbonOutline(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buffe
const cols = ribbon.colCount;
if (rowCount < 2) return null;
const points: number[] = [];
const push = (row: number, col: number): void => {
const sceneAt = (row: number, col: number): [number, number, number] => {
const i = (row * cols + col) * 3;
points.push(
return [
ribbon.positions[i] - origin.cx,
// 지형·서피스 위로 살짝 띄워 z-fight로 점선처럼 깜빡이는 것을 막는다.
ribbon.positions[i + 2] - origin.cz + 0.02,
-(ribbon.positions[i + 1] - origin.cy),
);
];
};
const push = (row: number, col: number): void => {
points.push(...sceneAt(row, col));
};
for (const col of [0, cols - 1]) {
for (let row = 0; row < rowCount - 1; row += 1) {
@@ -156,6 +174,7 @@ function ribbonOutline(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buffe
push(row + 1, col);
}
}
if (points.length < 6) return null;
return new THREE.BufferGeometry().setAttribute(
"position",
new THREE.Float32BufferAttribute(points, 3),
@@ -326,7 +345,11 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
polygonOffsetFactor: POLYGON_OFFSET_FACTOR,
polygonOffsetUnits: POLYGON_OFFSET_FACTOR,
});
const mesh = new THREE.Mesh(ribbonGeometry(ribbon, origin), material);
// 도로부(차도·노견·측구)는 구조물 위를 지나므로 도려내지 않는다 — 노면은 복토
// 위를 덮는 게 맞다. 절·성토 비탈만 구조물에 자리를 내준다.
const raw = ribbonGeometry(ribbon, origin);
// 절단은 Build가 행 단면 트림으로 이미 끝냈다(2026-08-25) — 여기서는 그대로 그린다.
const mesh = new THREE.Mesh(raw, material);
mesh.name = `corridor:${ribbon.kind}:${ribbon.side}`;
group.add(mesh);
const outline = ribbonOutline(ribbon, origin);
@@ -339,6 +362,73 @@ 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) => {
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: set.color }));
line.name = `corridor-plan:${set.name}:${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,249 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Region.ts
* 씬 수평면(x,z) 위의 **평면 영역**(사각 셀 격자 + 경계 선분) — 절단기
* (`_Corridor_Clip.ts`)가 "이 안쪽을 도려내라"고 넘기는 자료다. 700줄 제한으로
* `_Corridor_Clip.ts`에서 분리했다(2026-08-25).
*
* 영역은 `CorridorStrip`(코리도 외곽 — 좌우 catch line이 만드는 띠) 하나다. 지형에서
* 절·성토 점유분을 도려낼 때 쓴다. 구조물 절단은 여기 없다 — Build가 행 단면을
* 파라메트릭 경계(`_Corridor_Carve.ts`)로 미리 트림한다(2026-08-25).
*
* 셀(사각형) 단위 내부 판정이라 급곡선에서 외곽 폴리곤이 자기교차해도 견고하다.
* ========================================================================== */
import type { ModelBounds } from "./B05_Profile_UI_Markers";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
export interface P2 {
x: number;
z: number;
}
const EPS = 1e-9;
/** 구조물 상단과 서피스가 "같은 높이"로 볼 여유(m) — 딱 붙는 자리의 z-fight 방지. */
const COVER_TOLERANCE_M = 0.02;
export function cross2(ax: number, az: number, bx: number, bz: number): number {
return ax * bz - az * bx;
}
export function pointInTri2(p: P2, a: P2, b: P2, c: P2): boolean {
const d1 = cross2(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z);
const d2 = cross2(c.x - b.x, c.z - b.z, p.x - b.x, p.z - b.z);
const d3 = cross2(a.x - c.x, a.z - c.z, p.x - c.x, p.z - c.z);
const hasNeg = d1 < -EPS || d2 < -EPS || d3 < -EPS;
const hasPos = d1 > EPS || d2 > EPS || d3 > EPS;
return !(hasNeg && hasPos);
}
export function segmentsIntersect(a: P2, b: P2, c: P2, d: P2): boolean {
const d1 = cross2(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z);
const d2 = cross2(b.x - a.x, b.z - a.z, d.x - a.x, d.z - a.z);
const d3 = cross2(d.x - c.x, d.z - c.z, a.x - c.x, a.z - c.z);
const d4 = cross2(d.x - c.x, d.z - c.z, b.x - c.x, b.z - c.z);
return d1 * d2 < -EPS && d3 * d4 < -EPS;
}
/** 경계 선분 하나 — AABB를 함께 들고 있어 격자 후보 판정이 싸다. */
export interface RegionSegment {
p: P2;
q: P2;
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}
/** 절단기가 보는 평면 영역의 최소 규약. */
export interface PlanRegion {
readonly minX: number;
readonly maxX: number;
readonly minZ: number;
readonly maxZ: number;
/**
* 이 평면 점이 영역 안인가. `sceneY`를 주면 **그 표고까지 덮는 자리**만 안으로 본다
* — 구조물이 성토면 **아래**로 지나가면(복토 밑 구체 같은) 성토면을 지우면 안 된다
* (2026-08-25 사용자: 탑뷰에서 "노출되어야 하는" 면적만 투영한다).
*/
contains(p: P2, sceneY?: number): boolean;
segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[];
}
/**
* 사각 셀 + 경계 선분을 균일격자로 색인한 평면 영역.
*
* 격자가 없으면 지형 삼각형 수 × 셀 수가 되어 편집 반복 시 초 단위로 멎는다
* (2026-08-23 프리즈 보고). 격자로 후보만 본다.
*/
export class CellRegion implements PlanRegion {
protected cells: Array<{
a: P2;
b: P2;
c: P2;
d: P2;
minX: number;
maxX: number;
minZ: number;
maxZ: number;
}> = [];
/** 셀마다의 상단 씬 Y — 이 위로는 못 덮는다. 표고 개념이 없으면 Infinity. */
protected cellTops: number[] = [];
protected segments: RegionSegment[] = [];
minX = Infinity;
maxX = -Infinity;
minZ = Infinity;
maxZ = -Infinity;
private grid = 1;
private gridCols = 1;
private gridRows = 1;
private cellBuckets: number[][] = [];
private segmentBuckets: number[][] = [];
/** 셀(사각형) 하나 추가 — 꼭짓점은 a→b→c→d 한 바퀴 순서, `top`은 상단 씬 Y다. */
protected addCell(a: P2, b: P2, c: P2, d: P2, top = Infinity): void {
[a, b, c, d].forEach((point) => {
if (point.x < this.minX) this.minX = point.x;
if (point.x > this.maxX) this.maxX = point.x;
if (point.z < this.minZ) this.minZ = point.z;
if (point.z > this.maxZ) this.maxZ = point.z;
});
this.cellTops.push(top);
this.cells.push({
a,
b,
c,
d,
minX: Math.min(a.x, b.x, c.x, d.x),
maxX: Math.max(a.x, b.x, c.x, d.x),
minZ: Math.min(a.z, b.z, c.z, d.z),
maxZ: Math.max(a.z, b.z, c.z, d.z),
});
}
protected addSegment(p: P2, q: P2): void {
this.segments.push({
p,
q,
minX: Math.min(p.x, q.x),
maxX: Math.max(p.x, q.x),
minZ: Math.min(p.z, q.z),
maxZ: Math.max(p.z, q.z),
});
}
/** 셀·선분을 균일격자 버킷에 넣는다(격자 한 칸 ≈ 셀 한 변). */
protected buildIndex(): void {
if (!this.cells.length) return;
const meanCell =
this.cells.reduce((sum, cell) => sum + (cell.maxX - cell.minX) + (cell.maxZ - cell.minZ), 0) /
this.cells.length;
this.grid = Math.max(2, meanCell);
this.gridCols = Math.max(1, Math.ceil((this.maxX - this.minX) / this.grid) + 1);
this.gridRows = Math.max(1, Math.ceil((this.maxZ - this.minZ) / this.grid) + 1);
this.cellBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []);
this.segmentBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []);
const put = (
buckets: number[][],
index: number,
minX: number,
maxX: number,
minZ: number,
maxZ: number,
): void => {
const c0 = this.colOf(minX);
const c1 = this.colOf(maxX);
const r0 = this.rowOf(minZ);
const r1 = this.rowOf(maxZ);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) buckets[r * this.gridCols + c].push(index);
}
};
this.cells.forEach((cell, i) =>
put(this.cellBuckets, i, cell.minX, cell.maxX, cell.minZ, cell.maxZ),
);
this.segments.forEach((s, i) => put(this.segmentBuckets, i, s.minX, s.maxX, s.minZ, s.maxZ));
}
private colOf(x: number): number {
return Math.min(this.gridCols - 1, Math.max(0, Math.floor((x - this.minX) / this.grid)));
}
private rowOf(z: number): number {
return Math.min(this.gridRows - 1, Math.max(0, Math.floor((z - this.minZ) / this.grid)));
}
/** 셀이 하나도 없으면 아무것도 안 지운다(빈 영역). */
get isEmpty(): boolean {
return this.cells.length === 0;
}
contains(p: P2, sceneY?: number): boolean {
if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false;
const bucket = this.cellBuckets[this.rowOf(p.z) * this.gridCols + this.colOf(p.x)];
if (!bucket) return false;
for (const index of bucket) {
const cell = this.cells[index];
if (p.x < cell.minX || p.x > cell.maxX || p.z < cell.minZ || p.z > cell.maxZ) continue;
// 서피스가 셀 상단보다 위에 있으면 그 자리는 구조물에 가려지지 않는다 — 남긴다.
// 두 면이 딱 붙는 자리는 z-fight가 나므로 여유(COVER_TOLERANCE_M)만큼 봐 준다.
if (sceneY !== undefined && sceneY > this.cellTops[index] + COVER_TOLERANCE_M) continue;
if (pointInTri2(p, cell.a, cell.b, cell.c) || pointInTri2(p, cell.a, cell.c, cell.d)) {
return true;
}
}
return false;
}
/** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보(격자 후보만 검사). */
segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[] {
if (!this.segmentBuckets.length) return [];
const seen = new Set<number>();
const result: RegionSegment[] = [];
const c0 = this.colOf(minX);
const c1 = this.colOf(maxX);
const r0 = this.rowOf(minZ);
const r1 = this.rowOf(maxZ);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
for (const index of this.segmentBuckets[r * this.gridCols + c]) {
if (seen.has(index)) continue;
seen.add(index);
const s = this.segments[index];
if (s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ) result.push(s);
}
}
}
return result;
}
}
/** 모델 좌표(동·북) → 씬 수평면(x, z) 변환기 — 뷰어 공통 규약. */
export function scenePlanMapper(bounds: ModelBounds): (mx: number, my: number) => P2 {
const cx = (bounds.x[0] + bounds.x[1]) / 2;
const cy = (bounds.y[0] + bounds.y[1]) / 2;
return (mx: number, my: number): P2 => ({ x: mx - cx, z: -(my - cy) });
}
/** 코리도 스트립 — 좌우 catch line 사이를 프레임 사각형으로 담는다. */
export class CorridorStrip extends CellRegion {
constructor(build: CorridorBuildResult, bounds: ModelBounds) {
super();
const toScene = scenePlanMapper(bounds);
const left = build.outline.left.map(([mx, my]) => toScene(mx, my));
const right = build.outline.right.map(([mx, my]) => toScene(mx, my));
const count = Math.min(left.length, right.length);
for (let i = 0; i < count - 1; i += 1) {
this.addCell(left[i], right[i], right[i + 1], left[i + 1]);
}
for (let i = 0; i < count - 1; i += 1) {
this.addSegment(left[i], left[i + 1]);
this.addSegment(right[i], right[i + 1]);
}
if (count > 0) {
this.addSegment(left[0], right[0]);
this.addSegment(left[count - 1], right[count - 1]);
}
this.buildIndex();
}
}
+155 -135
View File
@@ -10,7 +10,7 @@
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { basinSpanOf, culvertLayoutOf, wallSpanOf } from "./B05_Profile_UI_Corridor_Structures";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill";
export type CorridorSide = "left" | "right" | "center";
@@ -78,8 +78,27 @@ export interface StationPieces {
* 늘리면 안 된다.
*/
wallSpans?: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>>;
/**
* 노견 확장 구간(2026-08-25 사용자 ①) — 구조물 실루엣의 수평 확장점까지 넓힌
* 노견을 구조물 구간 내내 유지하기 위한 제어점 범위. 비탈 교체(wallSpans)와
* 달리 **노견 조각에만** 걸린다.
*/
shoulderSpans?: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>>;
}
/**
* 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 로직의 on/off
* (2026-08-25 사용자 지시: 기존 성토면을 강제 변형시키던 로직 비활성화).
*
* 껐을 때의 동작: 성토·절토 비탈은 측점 설계선 그대로 지반까지 간다. 구조물과 겹치는
* 자리는 나중에 **탑뷰 투영면적으로 도려낸다**(`_Corridor_Region.ts`의 StructureFootprint
* → `_Corridor_Clip.ts`). 서피스를 비틀지 않고 잘라내는 쪽이 형상이 깨끗하다.
*
* 켜면 종전 동작(노폭 연장 + 구조물 실루엣 + 구간 내 지반 트림 금지)으로 돌아간다 —
* 되돌릴 일이 있어 코드는 남겨 둔다.
*/
const STRUCTURE_SILHOUETTE_OVERRIDE: boolean = false;
export function pieceKey(kind: CorridorKind, side: CorridorSide): string {
return `${kind}:${side}`;
}
@@ -288,146 +307,112 @@ export function classifyStation(section: CrossSection): StationPieces | null {
// 노폭 연장분은 비탈이 아니라 **노견 리본**을 넓히고, 집수정이 원지반을
// 파고들면 절토선 끝까지 이어 그 만큼 원지반을 걷어낸다.
let wallSpans: StationPieces["wallSpans"];
const culvertLayout = culvertLayoutOf(section);
if (culvertLayout) {
const layout = culvertLayout;
const inletSide: "left" | "right" =
(section.uphill_side ?? "left") === "left" ? "left" : "right";
const outletSide: "left" | "right" = inletSide === "left" ? "right" : "left";
const spans: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>> = {};
const collected = new Map<"left" | "right", OffsetPoint[]>();
const push = (
side: "left" | "right",
points: ReadonlyArray<{ offset: number; elevation: number }>,
): void => {
const bucket = collected.get(side) ?? [];
points.forEach((point) =>
bucket.push({ offset_m: point.offset, elevation_m: point.elevation }),
);
collected.set(side, bucket);
/**
* 구조물 실루엣 적용 — 그 측 비탈 조각을 통째로 갈아 끼우고 종방향 점유 구간을
* 등록한다. 배수관 세트·BOX암거·세월교가 공유한다(2026-08-25 사용자: 성토면 반영).
*
* `keepOrder`면 수집 순서(도로 → 바깥)를 지키고 내림차순일 때만 뒤집는다 — 거의
* 수직인 벽 앞면·다단 계단은 편거리 정렬이 순서를 보장하지 못한다. 뒤쪽 코드
* (`slopeRelative`)가 도로측 끝점을 **배열 위치**로 집으므로 항상 오름차순이어야 한다.
*/
const applySilhouette = (
side: "left" | "right",
rawPoints: OffsetPoint[],
span: { beforeM: number; afterM: number },
wanted: CorridorKind,
keepOrder: boolean,
): void => {
if (rawPoints.length < 2) return;
const outward = side === "left" ? 1 : -1;
const edge = {
offset_m: side === "left" ? roadL : roadR,
elevation_m: design.road_edges[side].elevation_m,
};
const trimSlopeOf = (side: "left" | "right") =>
side === "left" ? layout.designTrim?.maxSlope : layout.designTrim?.minSlope;
for (const wall of layout.walls) {
const side: "left" | "right" = wall.outward > 0 ? "left" : "right";
const slope = trimSlopeOf(side);
if (slope) push(side, slope.points);
// 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다.
push(side, [wall.topJoint, wall.bottomFront]);
spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet");
}
// 벽 하단 성토부선·다단 기슭막이(2026-08-23 사용자) — 유출측과 집수정 계류측.
// 유출부가 지면에 닿은 뒤의 **절토선**(kind="cut")과 유입 기슭막이 이후의 **접속선**
// (inletFill)은 넣지 않는다 — 2D 횡단도에서 지운 선이라 3D 지반 트림에도 반영해야
// 두 화면이 같은 형상이 된다(2026-08-24 사용자: "3D에 반영이 안 된 것 같다").
// 성토선 > 본 기슭막이 > **추가 성토선 > 추가 기슭막이** 순으로 번갈아 잇는다
// (2026-08-24 사용자 확정). 세그먼트를 다 밀고 나서 벽을 밀면 계단이 안팎을 오가는
// 순서가 깨져 3D 비탈이 엉킨다. 유출부 접지 후 절토선(kind="cut")은 넣지 않는다.
const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut");
const stepCount = Math.max(outletSegments.length, layout.extraWalls.length);
for (let step = 0; step < stepCount; step += 1) {
const segment = outletSegments[step];
if (segment) push(outletSide, segment.points);
const wall = layout.extraWalls[step];
if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]);
}
layout.basinFill.segments.forEach((segment) => push(inletSide, segment.points));
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
let basinCut = false;
if (layout.basin) {
const slope = trimSlopeOf(inletSide);
const trimOffset =
inletSide === "left" ? layout.designTrim?.maxOffset : layout.designTrim?.minOffset;
const trimElevation =
inletSide === "left" ? layout.designTrim?.maxElevation : layout.designTrim?.minElevation;
if (slope) {
push(inletSide, slope.points);
} else if (trimOffset != null && trimElevation != null && Number.isFinite(trimOffset)) {
push(inletSide, [
{
offset: inletSide === "left" ? roadL : roadR,
elevation: design.road_edges[inletSide].elevation_m,
},
{ offset: trimOffset, elevation: trimElevation },
]);
}
// 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다.
if (layout.basin.cutLine) {
push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]);
basinCut = true;
}
spans[inletSide] = basinSpanOf(section);
}
(["left", "right"] as const).forEach((side) => {
const span = spans[side];
const points = collected.get(side);
if (!span || !points || points.length < 2) return;
const outward = side === "left" ? 1 : -1;
const edge = {
offset_m: side === "left" ? roadL : roadR,
elevation_m: design.road_edges[side].elevation_m,
};
let silhouette = points;
// 노폭 연장(성토로 늘어난 노견)은 비탈이 아니라 노견 조각을 넓힌다.
if (!(ditchWidth > 0 && ditchSide === side)) {
let widening: OffsetPoint | null = null;
for (const point of silhouette) {
const outer = (point.offset_m - edge.offset_m) * outward;
if (outer > 1e-6 && Math.abs(point.elevation_m - edge.elevation_m) < 1e-6) {
if (!widening || (point.offset_m - widening.offset_m) * outward > 0) widening = point;
}
let silhouette = rawPoints;
// 노폭 연장(성토로 늘어난 노견)은 비탈이 아니라 노견 조각을 넓힌다.
if (!(ditchWidth > 0 && ditchSide === side)) {
let widening: OffsetPoint | null = null;
for (const point of silhouette) {
const outer = (point.offset_m - edge.offset_m) * outward;
if (outer > 1e-6 && Math.abs(point.elevation_m - edge.elevation_m) < 1e-6) {
if (!widening || (point.offset_m - widening.offset_m) * outward > 0) widening = point;
}
if (widening) {
const shoulder = pieces.get(pieceKey("shoulder", side));
if (shoulder && shoulder.length >= 2) {
// 원 노견 끝을 꼭짓점으로 남긴다 — 바깥 정점만 밀면 [차도끝→연장끝] 직선
// 하나가 되어 노견 물매가 연장분 전체로 퍼진다(2026-08-24 사용자 지적).
const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1];
const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m };
const outer = { offset_m: widening.offset_m, elevation_m: widening.elevation_m };
pieces.set(
pieceKey("shoulder", side),
side === "left" ? [inner, knee, outer] : [outer, knee, inner],
);
}
const cutAt = widening;
silhouette = silhouette.filter(
(point) => (point.offset_m - cutAt.offset_m) * outward > -1e-9,
}
if (widening) {
const shoulder = pieces.get(pieceKey("shoulder", side));
if (shoulder && shoulder.length >= 2) {
// 원 노견 끝을 꼭짓점으로 남긴다 — 바깥 정점만 밀면 [차도끝→연장끝] 직선
// 하나가 되어 노견 물매가 연장분 전체로 퍼진다(2026-08-24 사용자 지적).
const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1];
const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m };
const outer = { offset_m: widening.offset_m, elevation_m: widening.elevation_m };
pieces.set(
pieceKey("shoulder", side),
side === "left" ? [inner, knee, outer] : [outer, knee, inner],
);
// 연장분을 노견이 가져갔으면 비탈은 **연장 끝에서** 시작해야 한다. 남는 점이
// 없다고 그냥 빠지면 원 노견 끝에서 시작하는 옛 비탈이 살아남아 노견 밑을
// 파고든다(2026-08-24 실측: 성토비탈이 연장부와 0.9m 겹침).
if (silhouette.length < 2) silhouette = [widening, widening];
}
const cutAt = widening;
silhouette = silhouette.filter(
(point) => (point.offset_m - cutAt.offset_m) * outward > -1e-9,
);
// 연장분을 노견이 가져갔으면 비탈은 **연장 끝에서** 시작해야 한다. 남는 점이
// 없다고 그냥 빠지면 원 노견 끝에서 시작하는 옛 비탈이 살아남아 노견 밑을
// 파고든다(2026-08-24 실측: 성토비탈이 연장부와 0.9m 겹침).
if (silhouette.length < 2) silhouette = [widening, widening];
}
if (silhouette.length < 2) return;
// 유출측(성토 + 다단)은 **수집 순서 그대로** 잇는다 — 기슭막이 앞면이 거의 수직이라
// 편거리 정렬은 순서를 보장하지 못하고, 다단 계단은 안팎을 오가 뒤섞인다
// (2026-08-24 사용자: 위치·다단이 3D에 반영 안 됨). 유입측(집수정·절토)은 이번
// 범위 밖이라 종전 정렬을 유지한다.
if (side !== outletSide) {
silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m);
} else if (silhouette[0].offset_m > silhouette[silhouette.length - 1].offset_m) {
// 뒤쪽 코드(`slopeRelative`)가 도로측 끝점을 **배열 위치**로 집는다 — 오름차순
// 편거리 배열을 전제한다. 유출이 우측이면 수집 순서(도로 → 바깥)가 내림차순이라
// 바깥 끝을 도로측으로 잡아 **비탈 Z가 뒤집힌다**(2026-08-24 사용자 3D 제보).
// 정렬로 되돌리면 계단 순서가 다시 깨지므로 **뒤집기만** 한다 — 점 순서(체인)는
// 그대로이고 배열 방향만 오름차순이 된다.
silhouette = [...silhouette].reverse();
}
const kind: CorridorKind =
basinCut && side === inletSide ? "cut" : side === "left" ? leftRole : rightRole;
// 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다.
(["cut", "fill"] as const).forEach((other) => {
if (other !== kind) pieces.delete(pieceKey(other, side));
});
pieces.set(pieceKey(kind, side), resample(silhouette, PIECE_COLS[kind]));
wallSpans = wallSpans ?? {};
wallSpans[side] = span;
}
if (silhouette.length < 2) return;
if (!keepOrder) {
silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m);
} else if (silhouette[0].offset_m > silhouette[silhouette.length - 1].offset_m) {
silhouette = [...silhouette].reverse();
}
// 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다.
(["cut", "fill"] as const).forEach((other) => {
if (other !== wanted) pieces.delete(pieceKey(other, side));
});
pieces.set(pieceKey(wanted, side), resample(silhouette, PIECE_COLS[wanted]));
wallSpans = wallSpans ?? {};
wallSpans[side] = span;
};
// ── 구조물 측점(배수관 세트·BOX암거·세월교) — 그 측 비탈을 B06 횡단 실루엣으로
// 갈아 끼우고 종방향 점유 구간을 등록한다. 지금은 꺼져 있다
// (STRUCTURE_SILHOUETTE_OVERRIDE) — 성토면은 그대로 두고 투영커브로 도려낸다.
const silhouettes = structureSilhouettes(section);
for (const silhouette of STRUCTURE_SILHOUETTE_OVERRIDE ? silhouettes : []) {
const side = silhouette.side;
const kind: CorridorKind =
silhouette.kind === "auto" ? (side === "left" ? leftRole : rightRole) : silhouette.kind;
applySilhouette(side, silhouette.points, silhouette.span, kind, silhouette.keepOrder);
}
// ── 노견 확장(2026-08-25 사용자 ① — 상시 적용): 구조물 실루엣의 수평 확장점까지
// 노견 조각을 넓힌다. 원 노견 끝을 꼭짓점으로 남긴다(무릎 — 물매 유지). 넓힌
// 노견은 shoulderSpans 제어점으로 구조물 구간 내내 유지된다.
let shoulderSpans: StationPieces["shoulderSpans"];
for (const silhouette of silhouettes) {
const widen = silhouette.widenTo;
const side = silhouette.side;
if (!widen) continue;
if (ditchWidth > 0 && ditchSide === side) continue; // 측구 측은 종전대로.
const shoulder = pieces.get(pieceKey("shoulder", side));
if (!shoulder || shoulder.length < 2) continue;
const edge = {
offset_m: side === "left" ? roadL : roadR,
elevation_m: design.road_edges[side].elevation_m,
};
const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1];
const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m };
const outer = { offset_m: widen.offset_m, elevation_m: widen.elevation_m };
pieces.set(
pieceKey("shoulder", side),
side === "left" ? [inner, knee, outer] : [outer, knee, inner],
);
shoulderSpans = shoulderSpans ?? {};
shoulderSpans[side] = silhouette.span;
}
// 비탈 상대 좌표 — 안쪽(도로측) 끝점을 원점으로 둔다(행 앵커에 얹는 용도).
const slopeRelative = new Map<string, OffsetPoint[]>();
@@ -458,6 +443,7 @@ export function classifyStation(section: CrossSection): StationPieces | null {
slopeRelative,
ground,
wallSpans,
shoulderSpans,
ditchRelative: relativeToRoadEdge(pieces, {
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
@@ -474,6 +460,40 @@ export function classifyStation(section: CrossSection): StationPieces | null {
};
}
/**
* 단면(도로측 → 바깥)에서 **경계 편거리 안쪽을 잘라낸다**(2026-08-25 구조물 절단).
* 경계 바깥 조각만 남기고, 경계 지점을 보간해 첫 점으로 세운다 — 이 점이 행마다
* 이어져 절단선(메시 모서리)이 된다. 전부 안쪽이면 null(조각 드롭).
*/
export function cutInnerTo(
inner2outer: OffsetPoint[],
boundaryOffset: number,
side: "left" | "right",
): OffsetPoint[] | null {
const sign = side === "left" ? 1 : -1;
const beyond = (point: OffsetPoint): number => (point.offset_m - boundaryOffset) * sign;
const kept: OffsetPoint[] = [];
for (let i = 0; i < inner2outer.length; i += 1) {
const point = inner2outer[i];
if (beyond(point) >= -1e-9) {
if (!kept.length && i > 0) {
// 경계 통과 지점을 보간해 첫 점으로.
const prev = inner2outer[i - 1];
const span = point.offset_m - prev.offset_m;
const t = Math.abs(span) < 1e-12 ? 0 : (boundaryOffset - prev.offset_m) / span;
kept.push({
offset_m: boundaryOffset,
elevation_m: prev.elevation_m + (point.elevation_m - prev.elevation_m) * t,
});
}
kept.push({ ...point });
}
}
if (kept.length < 2) return null;
const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m);
return width < SLOPE_MIN_WIDTH_M ? null : kept;
}
/** 비탈 폭 하한(m) — 트림 후 이보다 좁으면 그 행에서 비탈이 없는 것으로 본다. */
export const SLOPE_MIN_WIDTH_M = 0.02;
/** 폴리라인 안에서 지반을 못 만날 때 마지막 구배로 연장하는 한계(m)·걸음(m). */
@@ -0,0 +1,284 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Station_Structure.ts
* BOX암거· ** **(· ) .
* `_Corridor_Station.ts`
* (2026-08-25 사용자: 3D에 ).
*
* 해결되는가: 실루엣이 ,
* · . **
* **
* .
*
* : +offset = . ** ** .
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B05_Profile_UI_Corridor_Station";
import {
basinSpanOf,
boxLayoutOf,
culvertLayoutOf,
fordLayoutOf,
wallSpanOf,
} from "./B05_Profile_UI_Corridor_Structures";
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
/** 한 측의 구조물 실루엣 — 비탈 조각을 이걸로 갈아 끼운다. */
export interface StructureSilhouette {
side: "left" | "right";
/** 도로측 → 바깥 순서의 단면 점. */
points: OffsetPoint[];
/** 기준 측점 전/후 점유 길이(m). */
span: { beforeM: number; afterM: number };
/** `"auto"` = 그 측점의 절·성토 판정(호출부의 leftRole/rightRole)을 그대로 따른다. */
kind: "cut" | "fill" | "auto";
/**
* .
* (2026-08-24 ).
*/
keepOrder: boolean;
/**
* (2026-08-25 )
* . ** **,
* ( z-fight).
*/
widenTo?: OffsetPoint;
/**
* ( ) `points` .
* (2026-08-25
* · ).
*/
patchPoints?: OffsetPoint[];
}
/** 실루엣에서 노견 확장점 찾기 — 첫 점(노견) 표고 그대로 바깥으로 나간 가장 먼 점. */
function widenToOf(points: OffsetPoint[], side: "left" | "right"): OffsetPoint | undefined {
if (points.length < 2) return undefined;
const sign = side === "left" ? 1 : -1;
const edge = points[0];
let widen: OffsetPoint | undefined;
for (const point of points) {
const outward = (point.offset_m - edge.offset_m) * sign;
if (outward > 1e-6 && Math.abs(point.elevation_m - edge.elevation_m) < 1e-6) {
if (!widen || (point.offset_m - widen.offset_m) * sign > 0) widen = point;
}
}
return widen;
}
/** 구체 폭의 절반씩 전·후로 걸친다 — 소유 측점 하나가 전 구간을 낸다. */
function halfSpanOf(spanM: number): { beforeM: number; afterM: number } {
const half = Math.max(spanM, 0.5) / 2;
return { beforeM: half, afterM: half };
}
/**
* BOX암거 ( ).
* 않는다: 2D , ··
* (2026-08-25 `2fe8f617` ).
*/
export function boxSilhouettes(section: CrossSection): StructureSilhouette[] {
const layout = boxLayoutOf(section);
if (!layout || !section.box) return [];
const span = halfSpanOf(section.box.span_m);
const groundAt = groundInterpolator(section.samples);
return layout.sides.flatMap((side) => {
if (side.fillLine.length < 2) return [];
// 유입(상단측)은 원지반이 구체 위에 있어 파내는 자리, 유출(하단측)은 쌓는 자리다
// (2026-08-25 사용자: 노선을 중심으로 유입·유출을 구분해 형상을 만든다). 측점마다
// 어느 쪽이 상단인지는 지반이 말해 준다 — 구체 최상단보다 지반이 높으면 절토다.
const ground = groundAt ? groundAt(side.offset) : null;
const kind: "cut" | "fill" =
ground != null && ground > side.topElevation + 0.05 ? "cut" : "fill";
const points = side.fillLine.map((point) => ({
offset_m: point.offset,
elevation_m: point.elevation,
}));
return [
{
side: side.role,
points,
span,
kind,
keepOrder: true,
widenTo: widenToOf(points, side.role),
},
];
});
}
/**
* , .
* 잇는다: 노견 ()
* () (= ).
* .
*/
export function fordSilhouettes(section: CrossSection): StructureSilhouette[] {
const layout = fordLayoutOf(section);
if (!layout || !section.ford) return [];
const edges = section.design?.road_edges;
if (!edges) return [];
const span = halfSpanOf(section.ford.span_m);
return layout.sides.flatMap((side) => {
const onLeft = side.outward > 0;
const wall = side.parts.find((part) => part.kind === "wall");
const floor = side.parts.find((part) => part.kind === "floor");
if (!wall || !floor) return [];
const edge = onLeft ? edges.left : edges.right;
// 접근선(노견 수평 연장 → 대각)이 있으면 그 경로로 벽 상단까지 온다 — 좌우
// 이동은 노폭 연장으로, 상하 이동만 성토선으로 나타난다(2026-08-25 사용자).
const points: OffsetPoint[] = side.approach
? side.approach.map((point) => ({ offset_m: point.offset, elevation_m: point.elevation }))
: [{ offset_m: edge.offset_m, elevation_m: edge.elevation_m }];
// 벽 상단(접근선 끝과 겹치면 생략) → 벽 상단 계류측 꼭짓점 — 여기서 벽 전면을
// 타고 바닥판으로 내려간다.
const tail = points[points.length - 1];
if (Math.hypot(tail.offset_m - side.top.offset, tail.elevation_m - side.top.elevation) > 1e-6) {
points.push({ offset_m: side.top.offset, elevation_m: side.top.elevation });
}
points.push({ offset_m: wall.points[2].offset, elevation_m: wall.points[2].elevation });
// 패치 리본(잘린 자리 다시 그리기)은 **성토부선만** 담는다 — 벽 전면·유로를
// 리본으로 덮으면 관 구멍과 L자 통로가 막힌다(2026-08-25 사용자 ②). 벽·바닥은
// 구조물 솔리드가 그린다.
const patchPoints: OffsetPoint[] = [];
// 유출측 다단과 달리 세월교는 단 나눔이 없다 — 접지 후 절토선(kind="cut")은 뺀다.
const fill = side.fillSegments.filter((segment) => segment.kind !== "cut");
let kind: "cut" | "fill" = "fill";
if (fill.length) {
fill.forEach((segment) =>
segment.points.forEach((point) => {
points.push({ offset_m: point.offset, elevation_m: point.elevation });
patchPoints.push({ offset_m: point.offset, elevation_m: point.elevation });
}),
);
} else if (side.cutLine) {
// 바닥판이 원지반 안으로 박혔다 — 그만큼 원지반을 걷어낸다.
const floorOuter = {
offset_m: floor.points[1].offset,
elevation_m: floor.points[1].elevation,
};
points.push(floorOuter);
patchPoints.push(floorOuter);
for (const cutPoint of [side.cutLine.from, side.cutLine.to]) {
points.push({ offset_m: cutPoint.offset, elevation_m: cutPoint.elevation });
patchPoints.push({ offset_m: cutPoint.offset, elevation_m: cutPoint.elevation });
}
kind = "cut";
} else {
points.push({
offset_m: floor.points[1].offset,
elevation_m: floor.points[1].elevation,
});
}
return [
{
side: onLeft ? ("left" as const) : ("right" as const),
points,
span,
kind,
keepOrder: true,
widenTo: widenToOf(points, onLeft ? "left" : "right"),
patchPoints,
},
];
});
}
/**
* (·) :
* ( = )
* () ( ).
*
* ****(kind="cut") ****
* 2D 3D도 (2026-08-24 ).
* > > ** > **
* 3D .
*/
export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] {
const layout = culvertLayoutOf(section);
const edges = section.design?.road_edges;
if (!layout || !edges) return [];
const inletSide: "left" | "right" = (section.uphill_side ?? "left") === "left" ? "left" : "right";
const outletSide: "left" | "right" = inletSide === "left" ? "right" : "left";
const spans: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>> = {};
const collected = new Map<"left" | "right", OffsetPoint[]>();
const push = (
side: "left" | "right",
points: ReadonlyArray<{ offset: number; elevation: number }>,
): void => {
const bucket = collected.get(side) ?? [];
points.forEach((point) =>
bucket.push({ offset_m: point.offset, elevation_m: point.elevation }),
);
collected.set(side, bucket);
};
const trimSlopeOf = (side: "left" | "right") =>
side === "left" ? layout.designTrim?.maxSlope : layout.designTrim?.minSlope;
for (const wall of layout.walls) {
const side: "left" | "right" = wall.outward > 0 ? "left" : "right";
const slope = trimSlopeOf(side);
if (slope) push(side, slope.points);
// 구조물 전면 실루엣 — 이음선 상단에서 전면 발끝까지 내려온다.
push(side, [wall.topJoint, wall.bottomFront]);
spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet");
}
const outletSegments = layout.outletFill.segments.filter((segment) => segment.kind !== "cut");
const stepCount = Math.max(outletSegments.length, layout.extraWalls.length);
for (let step = 0; step < stepCount; step += 1) {
const segment = outletSegments[step];
if (segment) push(outletSide, segment.points);
const wall = layout.extraWalls[step];
if (wall) push(outletSide, [wall.topJoint, wall.bottomFront]);
}
layout.basinFill.segments.forEach((segment) => push(inletSide, segment.points));
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
let basinCut = false;
if (layout.basin) {
const slope = trimSlopeOf(inletSide);
const trimOffset =
inletSide === "left" ? layout.designTrim?.maxOffset : layout.designTrim?.minOffset;
const trimElevation =
inletSide === "left" ? layout.designTrim?.maxElevation : layout.designTrim?.minElevation;
if (slope) {
push(inletSide, slope.points);
} else if (trimOffset != null && trimElevation != null && Number.isFinite(trimOffset)) {
const edge = inletSide === "left" ? edges.left : edges.right;
push(inletSide, [
{ offset: edge.offset_m, elevation: edge.elevation_m },
{ offset: trimOffset, elevation: trimElevation },
]);
}
// 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다.
if (layout.basin.cutLine) {
push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]);
basinCut = true;
}
spans[inletSide] = basinSpanOf(section);
}
const result: StructureSilhouette[] = [];
(["left", "right"] as const).forEach((side) => {
const span = spans[side];
const points = collected.get(side);
if (!span || !points || points.length < 2) return;
result.push({
side,
points,
span,
kind: basinCut && side === inletSide ? "cut" : "auto",
// 유출측(성토 + 다단)은 수집 순서 그대로, 유입측(집수정·절토)은 종전 정렬.
keepOrder: side === outletSide,
widenTo: widenToOf(points, side),
});
});
return result;
}
/** 그 측점에 붙은 구조물(배수관 세트·BOX암거·세월교) 실루엣 전부. 없으면 빈 배열. */
export function structureSilhouettes(section: CrossSection): StructureSilhouette[] {
if (section.box) return boxSilhouettes(section);
if (section.ford) return fordSilhouettes(section);
return culvertSilhouettes(section);
}
@@ -35,7 +35,9 @@ import {
splitByBand,
} from "./B05_Profile_UI_Corridor_Structures_Box";
import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -148,6 +150,25 @@ function storedAdjusts(
};
}
/** BOX암거 정본 조작값 — 확정 전 세션 값은 안 읽는다(2026-08-24 규칙). */
export function boxAdjustOf(section: CrossSection): BoxAdjust {
const stored = section.design?.box_adjust;
return {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) },
};
}
/** BOX암거 구체 기하 — 성토선 트림과 솔리드가 같은 계산을 나눠 쓴다(2026-08-25). */
export function boxLayoutOf(section: CrossSection): BoxLayout | null {
if (!section.box || !section.design) return null;
try {
return computeBoxLayout(section, section.samples, boxAdjustOf(section));
} catch {
return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
}
}
/** 세월교 구체 기하 — 배수관과 같은 규칙으로 **정본 조작값만** 읽는다(2026-08-25). */
export function fordLayoutOf(section: CrossSection): FordLayout | null {
if (!section.ford || !section.design) return null;
@@ -303,6 +324,8 @@ export function buildCorridorStructures(
minOffset: number;
maxOffset: number;
},
/** 보어(구멍) 중심 누가거리 목록 — 세월교는 련수만큼(2026-08-25 사용자 ⑥). */
boreChainages: number[] = [chainage],
): void => {
if (points.length < 3) return;
const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]);
@@ -313,19 +336,42 @@ export function buildCorridorStructures(
pushSwept(kind, points, beforeM, afterM);
return;
}
const chainages = boreRingChainages(
chainage - beforeM,
chainage + afterM,
SWEEP_STEP_M,
chainage,
bore.radius,
);
// 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다.
const chainageSet = new Set<number>();
for (const boreAt of boreChainages) {
boreRingChainages(
chainage - beforeM,
chainage + afterM,
SWEEP_STEP_M,
boreAt,
bore.radius,
).forEach((value) => chainageSet.add(value));
}
const chainages = [...chainageSet].sort((a, b) => a - b);
const rings = ringsAt(chainages);
const center = bore.centerAt((left + right) / 2);
// 단면 표고 범위 — 보어 원이 단면을 넘으면 그만큼 눌러 담는다(클램프). 안 그러면
// 위·아래 조각이 그 링에서 비어 조각째 버려지고 구멍이 찌그러진다(2026-08-25 ③).
const polyBottom = Math.min(...polygon.map(([, elevation]) => elevation));
const polyTop = Math.max(...polygon.map(([, elevation]) => elevation));
const upper: SectionPolygon[] = [];
const lower: SectionPolygon[] = [];
for (const at of chainages) {
const [top, bottom] = splitByBand(polygon, boreBandAt(at, chainage, bore.radius, center));
// 그 링에서 가장 가까운 보어 기준으로 구멍을 낸다 — 보어끼리는 안 겹친다.
let nearest = boreChainages[0];
for (const boreAt of boreChainages) {
if (Math.abs(at - boreAt) < Math.abs(at - nearest)) nearest = boreAt;
}
const band = boreBandAt(at, nearest, bore.radius, center);
const clamped = {
bottom: Math.max(band.bottom, polyBottom + 0.01),
top: Math.min(band.top, polyTop - 0.01),
};
if (clamped.bottom > clamped.top) {
clamped.bottom = center;
clamped.top = center;
}
const [top, bottom] = splitByBand(polygon, clamped);
upper.push(top);
lower.push(bottom);
}
@@ -337,14 +383,7 @@ export function buildCorridorStructures(
};
if (section.box) {
// 3D는 **정본만** 읽는다(2026-08-24 규칙) — 확정 전 세션 값은 반영하지 않는다.
const stored = section.design?.box_adjust;
solids.push(
...boxSolids(section, frameAt, stationFrame, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) },
}),
);
solids.push(...boxSolids(section, stationFrame, boxAdjustOf(section)));
}
if (fordLayout) {
@@ -356,15 +395,48 @@ export function buildCorridorStructures(
{ offset: invertPoints[3].offset, elevation: invertPoints[3].elevation },
{ offset: invertPoints[2].offset, elevation: invertPoints[2].elevation },
);
// 관은 련수만큼 폭 안에 등간격 — 구멍도 관마다 하나씩 낸다(2026-08-25 사용자 ⑥).
const count = Math.max(fordLayout.ford.pipe_count, 1);
const pipeChainages = Array.from(
{ length: count },
(_unused, i) => chainage - half + (2 * half * (i + 0.5)) / count,
);
for (const side of fordLayout.sides) {
for (const part of side.parts) pushPierced("basin", part.points, half, half, fordBore);
for (const part of side.parts) {
pushPierced("basin", part.points, half, half, fordBore, pipeChainages);
}
}
pushSwept("basin", fordLayout.slabBridge, half, half);
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
// 틀린 자리: 거긴 날개벽 투영이 만든 에이프런 끝이다). 발치는 바닥판 밑면,
// 시작 높이는 그 측 벽 상단이라 벽면에서 이어 나간다. 각도에 따른 바닥부
// 길이(slab_extend = 길이 × cos각)는 B06/백엔드 산식 그대로다.
solids.push(
...buildWingSolids(
stationFrame,
chainage,
half,
fordLayout.ford.wall_thickness_m,
fordLayout.sides.flatMap((side) => {
const wall = side.parts.find((part) => part.kind === "wall");
const floor = side.parts.find((part) => part.kind === "floor");
if (!wall || !floor) return [];
return [
{
// 벽 외측면 하단 모서리(points[3] = 하단-전면) — 여기서 벌어져 나간다.
offset: wall.points[3].offset,
topElevation: wall.points[2].elevation,
bottomElevation: floor.points[2].elevation,
wing: side.role === "inlet" ? fordLayout.ford.wing_in : fordLayout.ford.wing_out,
},
];
}),
),
);
// 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련).
const count = Math.max(fordLayout.ford.pipe_count, 1);
const invert = fordLayout.pipe.outer;
for (let i = 0; i < count; i += 1) {
const at = chainage - half + (2 * half * (i + 0.5)) / count;
for (const at of pipeChainages) {
const frame = frameAt(at);
solids.push({
chainage_m: at,
@@ -14,11 +14,8 @@
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { computeBoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { BoxAdjust } from "../B06_Section/B06_Section_UI_Cross_Box";
import type {
CorridorStructure,
RouteFrame,
StructureFrame,
} from "./B05_Profile_UI_Corridor_Structures";
import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing";
/** 단면 폴리곤 한 장 — [offset, elevation] 목록. */
export type SectionPolygon = Array<[number, number]>;
@@ -117,17 +114,31 @@ function wingOf(section: CrossSection, role: "left" | "right") {
return (role === "left") === inletOnLeft ? box.wing_in : box.wing_out;
}
/** 링 누가거리 목록 → 프레임(실패하면 측점 프레임으로 대체). */
function ringsAt(
/** 측점 프레임의 노선 진행 방향(좌향 벡터를 90° 돌린 단위벡터). */
function forwardOf(frame: StructureFrame): { dx: number; dy: number } {
return { dx: -frame.leftY, dy: frame.leftX };
}
/**
* . ** **(2026-08-25
* 사용자: BOX암거는 ).
* , .
*/
function straightRings(
chainages: number[],
frameAt: (chainageM: number) => RouteFrame | null,
fallback: StructureFrame,
chainage: number,
frame: StructureFrame,
): StructureFrame[] {
const { dx, dy } = forwardOf(frame);
return chainages.map((at) => {
const frame = frameAt(at);
return frame
? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 }
: fallback;
const along = at - chainage;
return {
cx: frame.cx + dx * along,
cy: frame.cy + dy * along,
leftX: frame.leftX,
leftY: frame.leftY,
dz: 0,
};
});
}
@@ -148,7 +159,6 @@ function repeat(
*/
export function boxSolids(
section: CrossSection,
frameAt: (chainageM: number) => RouteFrame | null,
stationFrame: StructureFrame,
adjust?: BoxAdjust,
): CorridorStructure[] {
@@ -165,7 +175,7 @@ export function boxSolids(
chainage + innerHalf,
chainage + halfSpan,
];
const bodyRings = ringsAt(bodyChainages, frameAt, stationFrame);
const bodyRings = straightRings(bodyChainages, chainage, stationFrame);
const solids: CorridorStructure[] = [
// 상판·저판은 구체 폭 전체에 걸친다. 좌·우 표고가 다르면 폴리곤째 기울어 있다.
{
@@ -184,9 +194,9 @@ export function boxSolids(
// 측벽 2매 — 내공 단면을 구체 폭의 바깥 구간에만 세운다(천장 아래 벽).
for (const sign of [-1, 1]) {
const wallRings = ringsAt(
const wallRings = straightRings(
[chainage + sign * halfSpan, chainage + sign * innerHalf],
frameAt,
chainage,
stationFrame,
);
solids.push({
@@ -232,88 +242,40 @@ export function boxSolids(
[inner, bottom],
] as SectionPolygon;
}),
rings: ringsAt(
rings: straightRings(
apronPlan.map((at) => chainage + at),
frameAt,
chainage,
stationFrame,
),
});
}
solids.push(...wingSolids(section, layout, frameAt));
solids.push(...wingSolids(section, layout, stationFrame));
return solids;
}
/**
* 4 (2026-08-25 사용자: 3D에
* ). ** **
* , .
* ). (`_Structures_Wing.ts`),
* .
*/
function wingSolids(
section: CrossSection,
layout: NonNullable<ReturnType<typeof computeBoxLayout>>,
frameAt: (chainageM: number) => RouteFrame | null,
stationFrame: StructureFrame,
): CorridorStructure[] {
const box = section.box;
if (!box) return [];
const chainage = section.chainage_m;
const here = frameAt(chainage);
const ahead = frameAt(chainage + 1) ?? here;
if (!here || !ahead) return [];
// 노선 진행 방향 — 앞 지점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다.
let dirX = ahead.cx - here.cx;
let dirY = ahead.cy - here.cy;
const norm = Math.hypot(dirX, dirY);
if (norm < 1e-6) {
dirX = -here.leftY;
dirY = here.leftX;
} else {
dirX /= norm;
dirY /= norm;
}
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const halfSpan = box.span_m / 2;
const thickness = box.wall_thickness_m;
const solids: CorridorStructure[] = [];
for (const side of layout.sides) {
const wing = (side.role === "left") === inletOnLeft ? box.wing_in : box.wing_out;
if (!wing.installed) continue;
const length = Math.max(wing.length_m ?? 0, 0);
if (length <= 0.05) continue;
const angle = ((wing.angle_deg ?? 45) * Math.PI) / 180;
const axisSign = side.role === "left" ? 1 : -1;
const height0 = side.topElevation - side.bottomElevation;
const height1 = Math.max(wing.height_m ?? height0, 0.3);
for (const along of [1, -1]) {
// 시작점은 **그 모서리 링의 프레임**으로 잡는다 — 중앙 프레임으로 잡으면 곡선
// 구간에서 구체 끝과 어긋난다(2026-08-25 사용자 지적).
const cornerFrame = frameAt(chainage + along * halfSpan) ?? here;
const startX = cornerFrame.cx + cornerFrame.leftX * side.offset;
const startY = cornerFrame.cy + cornerFrame.leftY * side.offset;
// 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터.
const wingX = here.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle);
const wingY = here.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;
const rings: StructureFrame[] = [];
const polygons: SectionPolygon[] = [];
const steps = 4;
for (let i = 0; i <= steps; i += 1) {
const t = (length * i) / steps;
// 패널 두께 방향 = 날개 축의 법선.
rings.push({ cx: startX + ux * t, cy: startY + uy * t, leftX: -uy, leftY: ux, dz: 0 });
const top = side.bottomElevation + height0 + ((height1 - height0) * i) / steps;
polygons.push([
[-thickness / 2, top],
[thickness / 2, top],
[thickness / 2, side.bottomElevation],
[-thickness / 2, side.bottomElevation],
]);
}
solids.push({ chainage_m: chainage, kind: "revet", polygons, rings });
}
}
return solids;
return buildWingSolids(
stationFrame,
section.chainage_m,
box.span_m / 2,
box.wall_thickness_m,
layout.sides.map((side) => ({
offset: side.offset,
topElevation: side.topElevation,
bottomElevation: side.bottomElevation,
wing: wingOf(section, side.role) ?? box.wing_out,
})),
);
}
@@ -0,0 +1,90 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Wing.ts
* () 4 BOX암거· (2026-08-25 :
* BOX암거와 ). `_Structures_Box.ts`
* (700 ).
*
* 규칙: 구조물 ( ± × · ) ** **
* . = () .
* (`height_m`) .
*
* ** **
* (2026-08-25 ).
* ========================================================================== */
import type { FordWingSpec } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
/** 날개벽이 붙는 구조물 한쪽 끝 — 좌·우 각각 하나. */
export interface WingAnchor {
/** 구체 끝 offset(+ = 좌측). */
offset: number;
/** 그 끝의 구체 최상단 표고. */
topElevation: number;
/** 그 끝의 바닥(발치) 표고. */
bottomElevation: number;
/** 그 측에 적용할 날개벽 제원. */
wing: FordWingSpec;
}
/** 날개 축 방향 분할 수 — 높이 체감을 담을 최소 해상도. */
const WING_STEPS = 4;
/**
* 4(· × ·). `anchors` .
*
* @param halfSpanM = ± .
* @param thicknessM (m).
*/
export function buildWingSolids(
frame: StructureFrame,
chainageM: number,
halfSpanM: number,
thicknessM: number,
anchors: WingAnchor[],
kind: CorridorStructure["kind"] = "revet",
): CorridorStructure[] {
// 노선 진행 방향 = 좌향 벡터를 90° 돌린 것.
const dirX = -frame.leftY;
const dirY = frame.leftX;
const solids: CorridorStructure[] = [];
for (const anchor of anchors) {
const wing = anchor.wing;
if (!wing?.installed) continue;
const length = Math.max(wing.length_m ?? 0, 0);
if (length <= 0.05) continue;
const angle = ((wing.angle_deg ?? 45) * Math.PI) / 180;
const axisSign = anchor.offset >= 0 ? 1 : -1;
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;
// 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터.
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 wingNorm = Math.hypot(wingX, wingY) || 1;
const ux = wingX / wingNorm;
const uy = wingY / wingNorm;
const rings: StructureFrame[] = [];
const polygons: SectionPolygon[] = [];
for (let i = 0; i <= WING_STEPS; i += 1) {
const t = (length * i) / WING_STEPS;
// 패널 두께 방향 = 날개 축의 법선.
rings.push({ cx: startX + ux * t, cy: startY + uy * t, leftX: -uy, leftY: ux, dz: 0 });
const top = anchor.bottomElevation + height0 + ((height1 - height0) * i) / WING_STEPS;
polygons.push([
[-thicknessM / 2, top],
[thicknessM / 2, top],
[thicknessM / 2, anchor.bottomElevation],
[-thicknessM / 2, anchor.bottomElevation],
]);
}
solids.push({ chainage_m: chainageM, kind, polygons, rings });
}
}
return solids;
}
@@ -0,0 +1,189 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Union.ts
* ** **( ) 2026-08-25 사용자: 커브들이
* .
*
* ( ):
* .
* ** ** .
* .
* , .
* ========================================================================== */
/** 실좌표 XY 점. */
export interface PXY {
x: number;
y: number;
}
/** 볼록 사각형 — 꼭짓점 4개(한 바퀴 순서) + AABB. */
export interface UnionQuad {
points: [PXY, PXY, PXY, PXY];
minX: number;
maxX: number;
minY: number;
maxY: number;
}
/** 좌표 스냅 눈금(m) — 끝점 이어붙이기·중복 제거 기준. */
const SNAP_M = 1e-3;
/** 겹침 판정 여유 — 이만큼 안쪽이면 "다른 조각 안"으로 본다. */
const INSIDE_EPS_M = 1e-4;
export function unionQuadOf(a: PXY, b: PXY, c: PXY, d: PXY): UnionQuad {
return {
points: [a, b, c, d],
minX: Math.min(a.x, b.x, c.x, d.x),
maxX: Math.max(a.x, b.x, c.x, d.x),
minY: Math.min(a.y, b.y, c.y, d.y),
maxY: Math.max(a.y, b.y, c.y, d.y),
};
}
function snapKey(point: PXY): string {
return `${Math.round(point.x / SNAP_M)}:${Math.round(point.y / SNAP_M)}`;
}
/** 볼록 사각형 안(경계 제외)인가 — 감김 방향과 무관하게 본다. */
function insideQuad(point: PXY, quad: UnionQuad): boolean {
if (
point.x < quad.minX - INSIDE_EPS_M ||
point.x > quad.maxX + INSIDE_EPS_M ||
point.y < quad.minY - INSIDE_EPS_M ||
point.y > quad.maxY + INSIDE_EPS_M
) {
return false;
}
let positive = 0;
let negative = 0;
for (let i = 0; i < 4; i += 1) {
const a = quad.points[i];
const b = quad.points[(i + 1) % 4];
const ex = b.x - a.x;
const ey = b.y - a.y;
const length = Math.hypot(ex, ey);
if (length < 1e-9) continue;
// 변에서의 부호 있는 거리 — 경계에 붙은 점은 "안"으로 안 센다.
const distance = (ex * (point.y - a.y) - ey * (point.x - a.x)) / length;
if (distance > INSIDE_EPS_M) positive += 1;
else if (distance < -INSIDE_EPS_M) negative += 1;
else return false;
}
return positive === 0 || negative === 0;
}
/** ① 모든 변을 서로의 교점에서 토막 낸다. */
function splitEdges(quads: ReadonlyArray<UnionQuad>): Array<[PXY, PXY]> {
const pieces: Array<[PXY, PXY]> = [];
for (let qi = 0; qi < quads.length; qi += 1) {
const quad = quads[qi];
for (let ei = 0; ei < 4; ei += 1) {
const a = quad.points[ei];
const b = quad.points[(ei + 1) % 4];
const dx = b.x - a.x;
const dy = b.y - a.y;
if (Math.hypot(dx, dy) < SNAP_M) continue;
const cuts = [0, 1];
for (let qj = 0; qj < quads.length; qj += 1) {
if (qj === qi) continue;
const other = quads[qj];
if (
other.minX > Math.max(a.x, b.x) ||
other.maxX < Math.min(a.x, b.x) ||
other.minY > Math.max(a.y, b.y) ||
other.maxY < Math.min(a.y, b.y)
) {
continue;
}
for (let ej = 0; ej < 4; ej += 1) {
const c = other.points[ej];
const d = other.points[(ej + 1) % 4];
const ex = d.x - c.x;
const ey = d.y - c.y;
const denominator = dx * ey - dy * ex;
if (Math.abs(denominator) < 1e-12) continue;
const t = ((c.x - a.x) * ey - (c.y - a.y) * ex) / denominator;
const u = ((c.x - a.x) * dy - (c.y - a.y) * dx) / denominator;
if (t > 1e-9 && t < 1 - 1e-9 && u > -1e-9 && u < 1 + 1e-9) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
for (let k = 0; k < cuts.length - 1; k += 1) {
const t0 = cuts[k];
const t1 = cuts[k + 1];
if (t1 - t0 < 1e-9) continue;
pieces.push([
{ x: a.x + dx * t0, y: a.y + dy * t0 },
{ x: a.x + dx * t1, y: a.y + dy * t1 },
]);
}
}
}
return pieces;
}
/**
* ** **. ( = ).
*/
export function unionOutline(quads: ReadonlyArray<UnionQuad>): PXY[][] {
if (!quads.length) return [];
// ② 내부 토막을 버린다. 두 가지가 내부다.
// · 다른 사각형 **안**으로 들어간 토막.
// · 두 사각형이 **맞대고 공유하는 변** — 두 번 나온다. 하나만 남기면 칸막이가
// 그대로 살아 격자가 된다(2026-08-25 실측: 루프 133개). 짝수 번 나온 토막은
// 통째로 버리고 **한 번만 나온 토막**(진짜 바깥 변)만 남긴다.
const pieces = splitEdges(quads).filter((piece) => {
const middle = { x: (piece[0].x + piece[1].x) / 2, y: (piece[0].y + piece[1].y) / 2 };
return !quads.some((quad) => insideQuad(middle, quad));
});
const counts = new Map<string, number>();
const keyOf = (piece: [PXY, PXY]): string =>
[snapKey(piece[0]), snapKey(piece[1])].sort().join("|");
for (const piece of pieces) counts.set(keyOf(piece), (counts.get(keyOf(piece)) ?? 0) + 1);
const taken = new Set<string>();
const kept: Array<[PXY, PXY]> = [];
for (const piece of pieces) {
const key = keyOf(piece);
if ((counts.get(key) ?? 0) !== 1 || taken.has(key)) continue;
taken.add(key);
kept.push(piece);
}
if (!kept.length) return [];
// ③ 끝점 인접표를 만들어 고리로 잇는다. 각 토막은 한 번씩만 쓴다.
const links = new Map<string, Array<{ index: number; to: PXY }>>();
const link = (key: string, index: number, to: PXY): void => {
const bucket = links.get(key);
if (bucket) bucket.push({ index, to });
else links.set(key, [{ index, to }]);
};
kept.forEach((piece, index) => {
link(snapKey(piece[0]), index, piece[1]);
link(snapKey(piece[1]), index, piece[0]);
});
const used = new Array<boolean>(kept.length).fill(false);
const loops: PXY[][] = [];
for (let start = 0; start < kept.length; start += 1) {
if (used[start]) continue;
used[start] = true;
const loop: PXY[] = [kept[start][0], kept[start][1]];
let cursor = kept[start][1];
// 토막 수를 넘기면 멈춘다 — 자료가 깨져도 무한 루프에 빠지지 않는다.
for (let guard = 0; guard < kept.length; guard += 1) {
if (snapKey(cursor) === snapKey(loop[0])) break;
const next = (links.get(snapKey(cursor)) ?? []).find((entry) => !used[entry.index]);
if (!next) break;
used[next.index] = true;
loop.push(next.to);
cursor = next.to;
}
if (loop.length < 3) continue;
// 닫아 준다 — 끝이 시작과 다르면 되돌아오는 선 하나를 더한다.
if (snapKey(loop[loop.length - 1]) !== snapKey(loop[0])) loop.push(loop[0]);
loops.push(loop);
}
return loops;
}
+6 -5
View File
@@ -14,6 +14,7 @@ import {
type RoutePointKind,
type SectionStationMarker,
} from "./B05_Profile_UI_Markers";
import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
@@ -146,8 +147,8 @@ export function createRouteViewer(): RouteViewer {
});
systemDarkTheme.addEventListener("change", updateSceneBackground);
updateSceneBackground();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000);
camera.position.set(100, 120, 100);
const cameraRig = createOrthoCameraRig();
const camera = cameraRig.camera;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const controls = new OrbitControls(camera, canvas);
@@ -213,8 +214,7 @@ export function createRouteViewer(): RouteViewer {
const width = Math.max(1, root.clientWidth);
const height = Math.max(1, root.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
cameraRig.setAspect(width / height);
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(root);
@@ -236,7 +236,8 @@ export function createRouteViewer(): RouteViewer {
camera.position.set(x, y, z);
camera.near = Math.max(0.1, distance / 1000);
camera.far = distance * 10;
camera.updateProjectionMatrix();
// 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다.
cameraRig.setHalfHeight(distance * 0.42);
controls.update();
}
@@ -0,0 +1,42 @@
/* =============================================================================
* B05_Profile_UI_Viewer_Camera.ts
* B05 **( ) **(2026-08-25 ) ·
* . camera.zoom이 (
* ), fit() . Viewer 700 .
* ========================================================================== */
import * as THREE from "three";
export interface OrthoCameraRig {
camera: THREE.OrthographicCamera;
/** 뷰포트 종횡비 반영(리사이즈 시). */
setAspect(aspect: number): void;
/** 절두체 반높이(월드 m) 지정 — fit()이 화면 배율을 잡을 때 쓴다. zoom은 1로 되돌린다. */
setHalfHeight(halfHeight: number): void;
}
export function createOrthoCameraRig(): OrthoCameraRig {
const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000);
camera.position.set(100, 120, 100);
let halfHeight = 100;
let aspect = 1;
const apply = (): void => {
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.updateProjectionMatrix();
};
return {
camera,
setAspect(value: number): void {
aspect = value;
apply();
},
setHalfHeight(value: number): void {
halfHeight = value;
camera.zoom = 1;
apply();
},
};
}
@@ -19,8 +19,9 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch
/** 마지막 조작 뒤 이만큼 조용하면 저장한다(ms). */
const SAVE_DELAY_MS = 800;
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. */
export type CulvertOptionPatch = Record<string, number>;
/** `pipe_points` .
* ("있음"/"없음") . */
export type CulvertOptionPatch = Record<string, number | string>;
export interface CulvertOptionWriter {
/** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
+14 -46
View File
@@ -9,6 +9,12 @@
* ========================================================================== */
import type { BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
import {
bindArrowKeys,
buildPanelShell,
dpadButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface BoxPanelDeps {
@@ -36,41 +42,10 @@ export interface BoxPanelHandle {
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(다른 조정창과 같은 2행 구조). */
function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
const row = document.createElement("div");
row.className = "b06-structure-panel__struct";
const label = document.createElement("span");
label.className = "b06-structure-panel__label";
label.textContent = labelText;
const controls = document.createElement("div");
controls.className = "b06-structure-panel__controls";
row.append(label, controls);
return { row, controls };
}
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
@@ -84,18 +59,10 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
if (current) run(current);
};
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약 — 2026-08-25 일원화).
const moveRow = makeRow("길이 ◀▶ · 표고 ▲▼");
moveRow.controls.classList.add("b06-structure-panel__buttons");
const dpad = (
label: string,
slot: "up" | "down" | "left" | "right" | "reset",
tip: string,
onClick: () => void,
): HTMLButtonElement => {
const button = makeButton(label, tip, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
};
const dpad = dpadButton;
moveRow.controls.append(
dpad(
"▲",
@@ -124,10 +91,9 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
),
);
const closeButton = makeButton("✕", "닫기", () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
root.append(value, moveRow.row);
root.append(title, closeButton, value, moveRow.row);
const arrows = bindArrowKeys(root, () => [moveRow.controls]);
function render(): void {
if (!current) return;
@@ -147,6 +113,8 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
arrows.detach();
if (role !== null) arrows.attach();
render();
},
};
+18 -1
View File
@@ -22,7 +22,7 @@ import type {
OffsetPoint,
OutletFillSegment,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
import { basinApproachPoints, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
@@ -74,6 +74,12 @@ export interface FordSideLayout {
fillSegments: OutletFillSegment[];
/** 원지반 안으로 박힐 때의 절토선. */
cutLine: BasinLayout["cutLine"];
/**
* (2026-08-25 ) **
* **, () ( ).
* null.
*/
approach: OffsetPoint[] | null;
/** 한계에 걸려 실제 적용된 조작값. */
adjust: FordWallAdjust;
}
@@ -193,11 +199,19 @@ export function computeFordLayout(
adjusts: [],
equalize: false,
});
// 접근선 — 좌우 이동분은 노견 표고 그대로 수평, 상하 이동분만 대각(1:1.2).
const approach = basinApproachPoints(
{ offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) },
outward,
{ innerWidthM: 0, innerHeightM: 0, lateralM, slopeM: wanted.slopeM },
{ offset: built.trimOffset, elevation: built.trimElevation },
);
return {
role,
outward,
parts: built.basin.parts,
top: { offset: built.trimOffset, elevation: built.trimElevation },
approach,
heightM,
slabTopElevation: built.pipeEnd.elevation,
pipeEnd: built.pipeEnd,
@@ -269,11 +283,14 @@ export function computeFordLayout(
text: `${ford.pipe_kind ?? "관"} Ø${Math.round(ford.diameter_m * 1000)}${countText}`,
},
// 노견 밖 설계선은 측벽이 대신한다 — 벽 상단 도로측 꼭짓점에서 끊는다.
// 이동이 있으면 접근선(노견 수평 연장 → 대각)을 실어 설계선이 그 경로로 온다.
designTrim: {
minOffset: right.top.offset,
maxOffset: left.top.offset,
minElevation: right.top.elevation,
maxElevation: left.top.elevation,
minSlope: right.approach ? { points: right.approach } : undefined,
maxSlope: left.approach ? { points: left.approach } : undefined,
},
adjust: { inlet: inlet.adjust, outlet: outlet.adjust },
};
+156 -81
View File
@@ -2,15 +2,24 @@
* B06_Section_UI_Cross_Ford_Panel.ts
* ** **(2026-08-25 ).
*
* (`_Cross_Structure_Panel.ts`) 4···
* 592 700 .
* **CSS ( D-pad·0.1m ) **.
* (`_Cross_Structure_Panel.ts`) 릿(
* `_Cross_Panel_Base.ts`) (·), 2 ,
* ** **, (2026-08-25 ).
*
* ( ): · · (1:1.2 ) · · .
* (B05 ).
* 조작: 측벽 · ( ) · (1:1.2 ) · · ·
* **(· ··)**(2026-08-25 ).
* ×cos() .
* ========================================================================== */
import type { FordWingSpec } from "./B06_Section_Api_Fetch";
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
import {
bindArrowKeys,
buildPanelShell,
dpadButton,
makeButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface FordPanelDeps {
@@ -31,6 +40,12 @@ export interface FordPanelDeps {
setPipeDiameterMm: (value: number) => void;
pipeCount: () => number;
setPipeCount: (value: number) => void;
/** 날개벽 제원(그 측) — 설치·짧은쪽 높이·길이·각도(2026-08-25 사용자). */
wingFor: (role: FordWallRole) => FordWingSpec | null;
setWing: (
role: FordWallRole,
patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>,
) => void;
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
slabLengthM: () => number;
/** 창을 닫는다 = 구조물 선택 해제. */
@@ -45,44 +60,15 @@ export interface FordPanelHandle {
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
const STEP_M = 0.1;
/** 날개벽 각도 한 걸음(°). */
const ANGLE_STEP_DEG = 5;
/** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */
const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500];
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(배수관 조정창과 같은 2행 구조). */
function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
const row = document.createElement("div");
row.className = "b06-structure-panel__struct";
const label = document.createElement("span");
label.className = "b06-structure-panel__label";
label.textContent = labelText;
const controls = document.createElement("div");
controls.className = "b06-structure-panel__controls";
row.append(label, controls);
return { row, controls };
}
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const shell = buildPanelShell(() => deps.close());
const { root, title } = shell;
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
@@ -110,46 +96,6 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
),
);
const moveRow = makeRow("이동");
moveRow.controls.classList.add("b06-structure-panel__buttons");
const dpad = (
label: string,
slot: "up" | "down" | "left" | "right" | "reset",
tip: string,
onClick: () => void,
): HTMLButtonElement => {
const button = makeButton(label, tip, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
};
moveRow.controls.append(
dpad(
"▲",
"up",
"사면 위로(1:1.2 대각)",
act((role) => deps.nudgeSlope(role, -STEP_M)),
),
dpad(
"◀",
"left",
"화면 왼쪽으로",
act((role) => deps.nudge(role, STEP_M)),
),
dpad("↺", "reset", "이 측벽 조정값 초기화", act(deps.reset)),
dpad(
"▶",
"right",
"화면 오른쪽으로",
act((role) => deps.nudge(role, -STEP_M)),
),
dpad(
"▼",
"down",
"사면 아래로(1:1.2 대각)",
act((role) => deps.nudgeSlope(role, STEP_M)),
),
);
const pipeRow = makeRow("관경 · 수량");
const diameter = document.createElement("select");
diameter.className = "b06-structure-panel__select";
@@ -171,10 +117,123 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
makeButton("", "관 수량 1련 늘리기", () => deps.setPipeCount(deps.pipeCount() + 1)),
);
const closeButton = makeButton("✕", "닫기", () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
// ── 날개벽(2026-08-25 사용자 — 제어 항목 추가): 설치 토글 + 높이·길이·각도.
// 각도는 관축 기준 벌어짐각 — 바닥판 연장 = 길이 × cos(각도)로 따라 바뀐다.
const wingRow = makeRow("날개벽");
const wingToggle = document.createElement("button");
wingToggle.type = "button";
wingToggle.className = "b06-structure-panel__btn b06-structure-panel__toggle";
wingToggle.textContent = "설치";
wingToggle.title = "날개벽 설치/해제 — 해제하면 바닥판 연장도 0이 된다.";
wingToggle.addEventListener("click", (event) => {
event.stopPropagation();
if (!current) return;
const wing = deps.wingFor(current);
deps.setWing(current, { installed: !(wing?.installed ?? false) });
});
wingRow.controls.append(wingToggle);
root.append(title, closeButton, value, heightRow.row, moveRow.row, pipeRow.row);
const wingValueRows: Array<{
row: HTMLElement;
value: HTMLSpanElement;
read: (wing: FordWingSpec) => string;
}> = [];
const makeWingValueRow = (
label: string,
stepText: string,
read: (wing: FordWingSpec) => string,
apply: (wing: FordWingSpec, direction: 1 | -1) => void,
): void => {
const parts = makeRow(label);
const readout = document.createElement("span");
readout.className = "b06-structure-panel__hval";
const nudgeWing = (direction: 1 | -1) => () => {
if (!current) return;
const wing = deps.wingFor(current);
if (wing) apply(wing, direction);
};
parts.controls.append(
makeButton("", `${label} ${stepText} 줄이기`, nudgeWing(-1)),
readout,
makeButton("", `${label} ${stepText} 늘리기`, nudgeWing(1)),
);
wingValueRows.push({ row: parts.row, value: readout, read });
};
makeWingValueRow(
"날개 짧은쪽 높이",
`${STEP_M}m`,
(wing) => `${(wing.height_m ?? 0).toFixed(1)}m`,
(wing, direction) => {
if (!current) return;
deps.setWing(current, {
height_m: Math.max(0.3, (wing.height_m ?? 1) + direction * STEP_M),
});
},
);
makeWingValueRow(
"날개 길이",
`${STEP_M}m`,
(wing) => `${(wing.length_m ?? 0).toFixed(1)}m`,
(wing, direction) => {
if (!current) return;
deps.setWing(current, {
length_m: Math.max(0, (wing.length_m ?? 0) + direction * STEP_M),
});
},
);
makeWingValueRow(
"날개 각도",
`${ANGLE_STEP_DEG}°`,
(wing) => `${Math.round(wing.angle_deg ?? 45)}°`,
(wing, direction) => {
if (!current) return;
deps.setWing(current, {
angle_deg: Math.min(85, Math.max(5, (wing.angle_deg ?? 45) + direction * ANGLE_STEP_DEG)),
});
},
);
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약).
const moveRow = makeRow("이동");
moveRow.controls.classList.add("b06-structure-panel__buttons");
moveRow.controls.append(
dpadButton(
"▲",
"up",
"사면 위로(1:1.2 대각)",
act((role) => deps.nudgeSlope(role, -STEP_M)),
),
dpadButton(
"◀",
"left",
"화면 왼쪽으로",
act((role) => deps.nudge(role, STEP_M)),
),
dpadButton("↺", "reset", "이 측벽 조정값 초기화", act(deps.reset)),
dpadButton(
"▶",
"right",
"화면 오른쪽으로",
act((role) => deps.nudge(role, -STEP_M)),
),
dpadButton(
"▼",
"down",
"사면 아래로(1:1.2 대각)",
act((role) => deps.nudgeSlope(role, STEP_M)),
),
);
root.append(
value,
heightRow.row,
pipeRow.row,
wingRow.row,
...wingValueRows.map((entry) => entry.row),
moveRow.row,
);
const arrows = bindArrowKeys(root, () => [moveRow.controls]);
function render(): void {
if (!current) return;
@@ -187,6 +246,14 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m (날개벽 각도 종속)`;
diameter.value = String(deps.pipeDiameterMm());
countValue.textContent = `${deps.pipeCount()}`;
const wing = deps.wingFor(current);
const installed = wing?.installed ?? false;
wingToggle.classList.toggle("is-on", installed);
wingToggle.setAttribute("aria-pressed", String(installed));
for (const entry of wingValueRows) {
entry.row.classList.toggle("is-hidden", !installed);
if (wing && installed) entry.value.textContent = entry.read(wing);
}
}
return {
@@ -194,6 +261,8 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
arrows.detach();
if (role !== null) arrows.attach();
render();
},
};
@@ -206,6 +275,12 @@ export interface FordControl {
reset: (chainageM: number, role: FordWallRole) => void;
/** 관경(mm)·수량(련) 저장 — B05 정본(`pipe_points`)으로 간다. */
setPipe: (chainageM: number, patch: { pipe_diameter_mm?: number; pipe_count?: number }) => void;
/** 날개벽 제원 저장(2026-08-25 사용자 — 조정창에서 직접 제어). 같은 정본으로 간다. */
setWing: (
chainageM: number,
role: FordWallRole,
patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>,
) => void;
/** 지금 선택된 측벽(측점별). */
selectedFor: (chainageM: number) => FordWallRole | null;
select: (chainageM: number, role: FordWallRole | null) => void;
@@ -0,0 +1,114 @@
/* =============================================================================
* B06_Section_UI_Cross_Panel_Base.ts
* ** **(2026-08-25 사용자: 세월교·BOX암거
* (`_Cross_Structure_Panel.ts`) 릿 ).
*
* 릿 :
* · (`__head`) = + .
* · = 1 + 2 (2026-08-22 2 ).
* · **(D-pad)·9 **(2026-08-24·08-25 ).
* · = (2026-08-23) · .
* ========================================================================== */
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작. */
export function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
const row = document.createElement("div");
row.className = "b06-structure-panel__struct";
const label = document.createElement("span");
label.className = "b06-structure-panel__label";
label.textContent = labelText;
const controls = document.createElement("div");
controls.className = "b06-structure-panel__controls";
row.append(label, controls);
return { row, controls };
}
/** D-pad 슬롯 버튼 — 방향키 바인딩이 슬롯 클래스로 버튼을 찾는다. */
export function dpadButton(
label: string,
slot: "up" | "down" | "left" | "right" | "reset" | "equal",
title: string,
onClick: () => void,
): HTMLButtonElement {
const button = makeButton(label, title, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
}
/** 창 뼈대 — root(.b06-structure-panel, 숨김 시작) + 머리줄(제목·닫기). */
export function buildPanelShell(onClose: () => void): {
root: HTMLElement;
head: HTMLElement;
title: HTMLSpanElement;
} {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const closeButton = makeButton("✕", "닫기", onClose);
closeButton.classList.add("b06-structure-panel__close");
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
head.append(title, closeButton);
root.append(head);
return { root, head, title };
}
const ARROW_SLOT: Record<string, string> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
/**
* = . `containers`
* . , .
* 반환: show/hide (attach) (detach).
*/
export function bindArrowKeys(
root: HTMLElement,
containers: () => HTMLElement[],
): { attach: () => void; detach: () => void } {
const onArrowKey = (event: KeyboardEvent): void => {
if (!root.isConnected) {
document.removeEventListener("keydown", onArrowKey);
return;
}
const slot = ARROW_SLOT[event.key];
if (!slot || root.classList.contains("is-hidden")) return;
const focus = event.target as HTMLElement | null;
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
for (const container of containers()) {
if (container.classList.contains("is-hidden")) continue;
const button = container.querySelector<HTMLButtonElement>(
`.b06-structure-panel__btn--${slot}`,
);
if (button) {
event.preventDefault();
button.click();
return;
}
}
};
return {
attach: () => document.addEventListener("keydown", onArrowKey),
detach: () => document.removeEventListener("keydown", onArrowKey),
};
}
@@ -16,6 +16,7 @@ import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
import { L } from "./B06_Section_UI_Section_Common";
import { makeButton, makeRow } from "./B06_Section_UI_Cross_Panel_Base";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface StructurePanelDeps {
@@ -93,33 +94,6 @@ const SPAN_STEP_M = 0.5;
* 0.5m로 0.25m라 0.1m / . */
const LENGTH_STEP_M = SPAN_STEP_M * 2;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 방금 맞춘 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(2026-08-22 사용자 확정 2행 구조). */
function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
const row = document.createElement("div");
row.className = "b06-structure-panel__struct";
const label = document.createElement("span");
label.className = "b06-structure-panel__label";
label.textContent = labelText;
const controls = document.createElement("div");
controls.className = "b06-structure-panel__controls";
row.append(label, controls);
return { row, controls };
}
/**
* .
* () (2026-08-24 ).
@@ -46,6 +46,12 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
setPipeDiameterMm: (value) => ford.setPipe(chainage, { pipe_diameter_mm: value }),
pipeCount: () => section.ford?.pipe_count ?? 1,
setPipeCount: (value) => ford.setPipe(chainage, { pipe_count: value }),
wingFor: (role) => {
const spec = section.ford;
if (!spec) return null;
return role === "inlet" ? spec.wing_in : spec.wing_out;
},
setWing: (role, patch) => ford.setWing(chainage, role, patch),
slabLengthM: context.slabLengthM,
close: context.close,
};
@@ -22,8 +22,8 @@ export interface FordControlDeps {
sectionAt: (chainageM: number) => CrossSection | undefined;
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
refreshCard: (chainageM: number) => void;
/** 관경·수량을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */
queuePipeOptions: (chainageM: number, patch: Record<string, number>) => void;
/** 관경·수량·날개벽 제원을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
round1: (value: number) => number;
clampMove: (value: number) => number;
}
@@ -118,6 +118,28 @@ export function createFordControls(deps: FordControlDeps): FordControls {
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
setWing: (chainageM, role, patch) => {
const spec = sectionAt(chainageM)?.ford;
const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out;
if (wing) {
// 캐시 먼저 — 바닥판 연장(길이 × cos각)도 백엔드 산식 그대로 다시 계산한다.
if (patch.installed !== undefined) wing.installed = patch.installed;
if (patch.height_m !== undefined) wing.height_m = patch.height_m;
if (patch.length_m !== undefined) wing.length_m = patch.length_m;
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wing.installed
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
const prefix = role === "inlet" ? "wing_in" : "wing_out";
const options: Record<string, number | string> = {};
if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음";
if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m;
if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m;
if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg;
deps.queuePipeOptions(chainageM, options);
deps.refreshCard(chainageM);
},
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
fordSelections.set(chainageM.toFixed(2), role);