From f6296fa7c541c578cb8c5946beb8d6d29cde743f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 25 Aug 2026 20:03:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=C2=B7B06):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=203D=20=ED=88=AC=EC=98=81=20=ED=91=9C=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=A7=81=EA=B5=90=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=C2=B7=20=EC=A1=B0=EC=A0=95=EC=B0=BD=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- B04_PreProcess/B04_PreProcess_UI_Camera.ts | 8 +- B05_Profile/B05_Profile_UI_Corridor.ts | 2 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 353 +++++++------ B05_Profile/B05_Profile_UI_Corridor_Carve.ts | 499 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Corridor_Clip.ts | 432 ++++++--------- B05_Profile/B05_Profile_UI_Corridor_Frames.ts | 143 ++++- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 98 +++- B05_Profile/B05_Profile_UI_Corridor_Region.ts | 249 +++++++++ .../B05_Profile_UI_Corridor_Station.ts | 290 +++++----- ...5_Profile_UI_Corridor_Station_Structure.ts | 284 ++++++++++ .../B05_Profile_UI_Corridor_Structures.ts | 114 +++- .../B05_Profile_UI_Corridor_Structures_Box.ts | 128 ++--- ...B05_Profile_UI_Corridor_Structures_Wing.ts | 90 ++++ B05_Profile/B05_Profile_UI_Corridor_Union.ts | 189 +++++++ B05_Profile/B05_Profile_UI_Viewer.ts | 11 +- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 42 ++ .../B06_Section_Api_Culvert_Options.ts | 5 +- B06_Section/B06_Section_UI_Cross_Box_Panel.ts | 60 +-- B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 19 +- .../B06_Section_UI_Cross_Ford_Panel.ts | 237 ++++++--- .../B06_Section_UI_Cross_Panel_Base.ts | 114 ++++ .../B06_Section_UI_Cross_Structure_Panel.ts | 28 +- B06_Section/B06_Section_UI_Cross_View_Ford.ts | 6 + .../B06_Section_UI_Page_Ford_Controls.ts | 26 +- 24 files changed, 2569 insertions(+), 858 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Carve.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Region.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Union.ts create mode 100644 B05_Profile/B05_Profile_UI_Viewer_Camera.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Panel_Base.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 643f84f1..7a40a3a5 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -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(); diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 0b1c10a6..19248d35 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 4d1e1c89..a6041817 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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>; + /** 구조물(기슭막이·집수정·구체·날개벽) 탑뷰 외곽선 — 성토라인과 **따로** 그린다. */ + structurePlan?: Array>; + /** + * 성토부 **탑뷰 외곽선**(2026-08-25 사용자) — **구조물이 없다고 보고** 원본 + * 성토·절토면의 바깥 윤곽을 유입/유출로 나눠 담은 닫힌 고리들. z는 0이고 그리기 + * 쪽이 평면에 얹는다. + */ + fillPlan?: { + inlet: Array>; + outlet: Array>; + }; } +/** + * 절단 적용 여부(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(); (["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(); + // 이 행의 절단 투영점(측별) — 비탈 표면 위 표고로. + const rowTraces: Partial> = {}; /** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */ const fixedSlopeKeys = new Set(); /** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */ @@ -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) }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts new file mode 100644 index 00000000..21ce8d5e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -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): [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> = []; + + 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, + kinds?: ReadonlySet, +): 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): CarveFootprint { + const footprint = new CarveFootprint(); + addStructureQuads(footprint, structures, new Set(["revet"])); + return footprint; +} + +/** + * 탑뷰 투영 풋프린트를 만든다 — 구조물 솔리드의 링 사이 사각형 + 실루엣 패치 영역. + */ +export function buildCarveFootprint( + crossSections: ReadonlyArray, + structures: ReadonlyArray, + frameAt: (chainageM: number) => RouteFrame | null, +): CarveFootprint { + const footprint = new CarveFootprint(); + addStructureQuads(footprint, structures); + addDeformedFillQuads(footprint, crossSections, frameAt); + return footprint; +} + +/** 구조물 솔리드만의 투영 풋프린트 — 표기용(성토라인과 **따로** 그린다). */ +export function buildStructureOnlyFootprint( + structures: ReadonlyArray, +): CarveFootprint { + const footprint = new CarveFootprint(); + addStructureQuads(footprint, structures); + return footprint; +} + +/** 실루엣 패치 영역 — [확장 노견 → 실루엣 바깥 끝]을 점유 구간 내내(성토부 하단까지). */ +function addDeformedFillQuads( + footprint: CarveFootprint, + crossSections: ReadonlyArray, + 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> { + const traces: Array> = []; + 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> { + 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, +): (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): 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> { + const loops: Array> = []; + 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, + structures: ReadonlyArray, + 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; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts index 0eb6fcd6..e78bd400 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts @@ -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; + 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, 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(); - 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, + 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 } + ).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, ): 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 }).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, 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, +): 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); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Frames.ts b/B05_Profile/B05_Profile_UI_Corridor_Frames.ts index 562d141e..6b18e027 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Frames.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Frames.ts @@ -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; + }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 4e740e74..c8292fc5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -34,6 +34,21 @@ const KIND_COLORS: Record = { 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>; + }> = [{ 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; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Region.ts b/B05_Profile/B05_Profile_UI_Corridor_Region.ts new file mode 100644 index 00000000..3ed925ba --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Region.ts @@ -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(); + 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(); + } +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 3869aeb4..53f88377 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -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>; + /** + * 노견 확장 구간(2026-08-25 사용자 ①) — 구조물 실루엣의 수평 확장점까지 넓힌 + * 노견을 구조물 구간 내내 유지하기 위한 제어점 범위. 비탈 교체(wallSpans)와 + * 달리 **노견 조각에만** 걸린다. + */ + shoulderSpans?: Partial>; } +/** + * 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 로직의 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> = {}; - 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(); @@ -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). */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts new file mode 100644 index 00000000..a39115df --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -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> = {}; + 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); +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index ac712a64..10abed4c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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(); + 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, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts index 84093cd8..d85f6dad 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Box.ts @@ -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>, - 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, + })), + ); } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts new file mode 100644 index 00000000..5c8e3084 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Wing.ts @@ -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; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Union.ts b/B05_Profile/B05_Profile_UI_Corridor_Union.ts new file mode 100644 index 00000000..c7ad854c --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Union.ts @@ -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): 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): 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(); + 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(); + 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>(); + 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(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; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index dfe36ad6..f765ccf8 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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(); } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts new file mode 100644 index 00000000..6e36d63b --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -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(); + }, + }; +} diff --git a/B06_Section/B06_Section_Api_Culvert_Options.ts b/B06_Section/B06_Section_Api_Culvert_Options.ts index 0ca3d4a4..3b08d6cd 100644 --- a/B06_Section/B06_Section_Api_Culvert_Options.ts +++ b/B06_Section/B06_Section_Api_Culvert_Options.ts @@ -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; +/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. + * 값은 수치가 대부분이나 날개벽 설치("있음"/"없음")처럼 문자열도 있다. */ +export type CulvertOptionPatch = Record; export interface CulvertOptionWriter { /** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */ diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index a8281f77..a4760a28 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -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(); }, }; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index 65a0aaa7..6c480525 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -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 }, }; diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 7a5d3144..1cf5e408 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts new file mode 100644 index 00000000..017e2716 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -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 = { + 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( + `.b06-structure-panel__btn--${slot}`, + ); + if (button) { + event.preventDefault(); + button.click(); + return; + } + } + }; + return { + attach: () => document.addEventListener("keydown", onArrowKey), + detach: () => document.removeEventListener("keydown", onArrowKey), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index ed433ed2..e82a2a7d 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -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 사용자). diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index 92a39e2b..ee4a415c 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -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, }; diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 08e42e25..ef97eaec 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -22,8 +22,8 @@ export interface FordControlDeps { sectionAt: (chainageM: number) => CrossSection | undefined; patchCachedDesign: (chainageM: number, patch: Partial) => void; refreshCard: (chainageM: number) => void; - /** 관경·수량을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */ - queuePipeOptions: (chainageM: number, patch: Record) => void; + /** 관경·수량·날개벽 제원을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */ + queuePipeOptions: (chainageM: number, patch: Record) => 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 = {}; + 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);