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>
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Ford_Geom.ts
|
|
* 세월교 구체 **기하 계산** — 그리기(`_Cross_Ford.ts`)와 분리(700줄 제한).
|
|
* 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지).
|
|
*
|
|
* 구성(2026-08-25 사용자 확정):
|
|
* · 양측 **측벽** = ㄴ형 집수정을 그대로 재사용한다(`buildBasin`) — 전면 1:0.3
|
|
* 기움·좌우·상하(대각) 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다.
|
|
* · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각)
|
|
* 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다.
|
|
* · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다.
|
|
* · 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후로 절반씩 걸친다
|
|
* (붙는 측점 판정은 백엔드 `attach_culvert_sets`).
|
|
* 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
|
|
import { pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
|
import type {
|
|
BasinLayout,
|
|
CulvertDesignTrim,
|
|
OffsetPoint,
|
|
OutletFillSegment,
|
|
} from "./B06_Section_UI_Cross_Culvert_Types";
|
|
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";
|
|
|
|
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
|
|
export interface FordWallAdjust {
|
|
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */
|
|
heightM: number | null;
|
|
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
|
|
lateralM: number;
|
|
/** 상하(m, 수평 성분, + = 사면 아래) — 집수정과 같이 1:1.2 대각으로 내려간다. */
|
|
slopeM: number;
|
|
}
|
|
|
|
/** 측점별 세월교 조정값 — 유입·유출 측벽을 따로 잡는다. */
|
|
export interface FordAdjust {
|
|
inlet: FordWallAdjust;
|
|
outlet: FordWallAdjust;
|
|
}
|
|
|
|
export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = {
|
|
heightM: null,
|
|
lateralM: 0,
|
|
slopeM: 0,
|
|
};
|
|
|
|
export const DEFAULT_FORD_ADJUST: FordAdjust = {
|
|
inlet: DEFAULT_FORD_WALL_ADJUST,
|
|
outlet: DEFAULT_FORD_WALL_ADJUST,
|
|
};
|
|
|
|
export type FordWallRole = "inlet" | "outlet";
|
|
|
|
export interface FordSideLayout {
|
|
role: FordWallRole;
|
|
/** 계류측 방향 부호(+ = 좌). */
|
|
outward: number;
|
|
/** 벽·바닥판 폴리곤(집수정 ㄴ형과 같은 부재 구성). */
|
|
parts: BasinLayout["parts"];
|
|
/** 벽 상단 도로측 꼭짓점 = 설계선 트림 경계. */
|
|
top: OffsetPoint;
|
|
heightM: number;
|
|
/** 바닥판 상면 표고(이동 반영) = 이쪽 관 끝의 invert. */
|
|
slabTopElevation: number;
|
|
/** 관 끝점(벽 바깥면 × 바닥판 상면). */
|
|
pipeEnd: OffsetPoint;
|
|
/** 바닥판 안쪽 변(위·아래) — 두 측벽 사이를 잇는 판이 여기서 시작한다. */
|
|
slabInner: [OffsetPoint, OffsetPoint];
|
|
/** 구조물 계류측 끝이 원지반 위일 때의 성토부선(집수정과 같은 체계). */
|
|
fillSegments: OutletFillSegment[];
|
|
/** 원지반 안으로 박힐 때의 절토선. */
|
|
cutLine: BasinLayout["cutLine"];
|
|
/**
|
|
* 노견 → 벽 상단 접근선(2026-08-25 사용자) — **좌우 이동은 노견이 수평으로
|
|
* 확장**되고, 상하(대각) 이동분만 성토선이 된다(집수정과 같은 규칙).
|
|
* 이동이 없으면 null.
|
|
*/
|
|
approach: OffsetPoint[] | null;
|
|
/** 한계에 걸려 실제 적용된 조작값. */
|
|
adjust: FordWallAdjust;
|
|
}
|
|
|
|
export interface FordLayout {
|
|
ford: FordSet;
|
|
sides: FordSideLayout[];
|
|
/** 두 측벽 사이를 잇는 바닥판. */
|
|
slabBridge: OffsetPoint[];
|
|
/** 바닥판 전체 길이(m) — 날개벽 연장 포함. */
|
|
slabLengthM: number;
|
|
/** 관 외곽·내경 사각형(양 끝은 각 측 바닥판 상면). */
|
|
pipe: { outer: OffsetPoint[]; inner: OffsetPoint[] };
|
|
label: { at: OffsetPoint; text: string };
|
|
designTrim: CulvertDesignTrim;
|
|
adjust: FordAdjust;
|
|
}
|
|
|
|
/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */
|
|
function minWallHeight(ford: FordSet): number {
|
|
return ford.diameter_m + ford.min_cover_m;
|
|
}
|
|
|
|
/** 성토 채움 윗면을 만들 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선. */
|
|
function roadTopFactory(
|
|
section: CrossSection,
|
|
left: { offset_m: number; elevation_m: number },
|
|
right: { offset_m: number; elevation_m: number },
|
|
): (offset: number) => number {
|
|
const designAt = designInterpolator(section.design?.design_line);
|
|
if (designAt) return designAt;
|
|
const span = left.offset_m - right.offset_m;
|
|
return (offset: number): number => {
|
|
if (Math.abs(span) < 1e-6) return left.elevation_m;
|
|
const t = (offset - right.offset_m) / span;
|
|
return right.elevation_m + (left.elevation_m - right.elevation_m) * t;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 세월교 구체 기하. 제원·설계선·지반이 부족하면 null (그리기와 분리 — 설계선 트림이
|
|
* 먼저 쓴다).
|
|
*/
|
|
export function computeFordLayout(
|
|
section: CrossSection,
|
|
groundSamples: SectionSample[],
|
|
adjust: FordAdjust = DEFAULT_FORD_ADJUST,
|
|
): FordLayout | null {
|
|
const ford = section.ford;
|
|
if (!ford) return null;
|
|
const edges = section.design?.road_edges;
|
|
if (!edges) return null;
|
|
const groundAt = groundInterpolator(groundSamples);
|
|
if (!groundAt) return null;
|
|
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
|
|
const minSample = Math.min(...sampleOffsets);
|
|
const maxSample = Math.max(...sampleOffsets);
|
|
if (!(maxSample > minSample)) return null;
|
|
|
|
// 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백).
|
|
const uphill = section.uphill_side ?? "left";
|
|
const inletOnLeft = uphill === "left";
|
|
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
|
|
const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0;
|
|
|
|
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
|
|
const bedElevation = (() => {
|
|
let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m));
|
|
for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) {
|
|
low = Math.min(low, groundAt(offset));
|
|
}
|
|
return low;
|
|
})();
|
|
|
|
const buildSide = (role: FordWallRole): FordSideLayout | null => {
|
|
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
|
|
const edge = onLeft ? edges.left : edges.right;
|
|
const outward = onLeft ? 1 : -1;
|
|
const limitOffset = onLeft ? maxSample : minSample;
|
|
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
|
|
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
|
|
const lateralM = Math.max(wanted.lateralM, 0);
|
|
const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m);
|
|
const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford));
|
|
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
|
|
const anchor: OffsetPoint = {
|
|
offset: edge.offset_m,
|
|
elevation: roadTopAt(edge.offset_m) - heightM,
|
|
};
|
|
const built = buildBasin({
|
|
anchor,
|
|
outward,
|
|
shape: "L",
|
|
reason: "manual",
|
|
diameterM: ford.diameter_m,
|
|
edge: { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) },
|
|
groundAt,
|
|
cutSlopeRatio,
|
|
// 내공 폭 자리에 날개벽 투영 연장을 넣는다 — 바닥판이 그만큼 계류측으로 뻗는다.
|
|
adjust: {
|
|
innerWidthM: Math.max(wing.slab_extend_m, 0),
|
|
innerHeightM: heightM,
|
|
lateralM,
|
|
slopeM: wanted.slopeM,
|
|
},
|
|
memberThicknessM: ford.wall_thickness_m,
|
|
floorThicknessM: ford.slab_thickness_m,
|
|
});
|
|
const wall = built.basin.parts.find((part) => part.kind === "wall");
|
|
const floor = built.basin.parts.find((part) => part.kind === "floor");
|
|
if (!wall || !floor) return null;
|
|
const extras = buildExtrasAt(built.fillStart, {
|
|
startBottomElevation: built.fillBottomElevation,
|
|
outward,
|
|
groundAt,
|
|
limitOffset,
|
|
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,
|
|
slabInner: [floor.points[0], floor.points[3]],
|
|
fillSegments: extras.segments,
|
|
cutLine: built.basin.cutLine,
|
|
adjust: {
|
|
heightM: wanted.heightM === null ? null : heightM,
|
|
lateralM,
|
|
slopeM: wanted.slopeM,
|
|
},
|
|
};
|
|
};
|
|
|
|
const inlet = buildSide("inlet");
|
|
const outlet = buildSide("outlet");
|
|
if (!inlet || !outlet) return null;
|
|
const sides = [inlet, outlet];
|
|
const left = inlet.outward > 0 ? inlet : outlet;
|
|
const right = inlet.outward > 0 ? outlet : inlet;
|
|
|
|
// 두 측벽 사이를 잇는 바닥판 — 각 측 바닥판 안쪽 변을 그대로 잇는다.
|
|
const slabBridge: OffsetPoint[] = [
|
|
left.slabInner[0],
|
|
right.slabInner[0],
|
|
right.slabInner[1],
|
|
left.slabInner[1],
|
|
];
|
|
const slabLengthM = Math.abs(
|
|
Math.max(...sides.map((side) => side.parts[1].points[1].offset)) -
|
|
Math.min(...sides.map((side) => side.parts[1].points[1].offset)),
|
|
);
|
|
|
|
// 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다. 양측 표고가 다르면 그만큼 기운다.
|
|
const pipeWall = pipeWallThicknessM(ford.pipe_kind, ford.diameter_m);
|
|
const leftInvert = left.pipeEnd;
|
|
const rightInvert = right.pipeEnd;
|
|
const rise = (point: OffsetPoint, dz: number): OffsetPoint => ({
|
|
offset: point.offset,
|
|
elevation: point.elevation + dz,
|
|
});
|
|
const pipe = {
|
|
outer: [
|
|
rise(leftInvert, ford.diameter_m),
|
|
rise(rightInvert, ford.diameter_m),
|
|
rightInvert,
|
|
leftInvert,
|
|
],
|
|
inner: [
|
|
rise(leftInvert, ford.diameter_m - pipeWall),
|
|
rise(rightInvert, ford.diameter_m - pipeWall),
|
|
rise(rightInvert, pipeWall),
|
|
rise(leftInvert, pipeWall),
|
|
],
|
|
};
|
|
|
|
const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : "";
|
|
return {
|
|
ford,
|
|
sides,
|
|
slabBridge,
|
|
slabLengthM,
|
|
pipe,
|
|
label: {
|
|
at: {
|
|
offset: (leftInvert.offset + rightInvert.offset) / 2,
|
|
elevation: (leftInvert.elevation + rightInvert.elevation) / 2 + ford.diameter_m / 2,
|
|
},
|
|
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 },
|
|
};
|
|
}
|