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:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
/** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user