From 398b5bdffdfa0d039d80c19db05ef0170a01f886 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 26 Aug 2026 21:51:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=ED=83=91=EB=B7=B0=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=B9=98=20=ED=8C=8C=EB=9E=80=20=EC=BB=A4=EB=B8=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=88=C2=B7=EC=84=B1=ED=86=A0=EB=A9=B4=EC=97=90?= =?UTF-8?q?=20=ED=88=AC=EC=98=81=ED=95=B4=20=EB=B9=A8=EA=B0=84=20=EC=84=A0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=96=B9=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물 상단 +5m 평면에 눕혀 두던 파란 스케치(slope-original, 유입 실선·유출 파선)의 XY를 그대로 쓰고 z만 그 자리 절·성토면에서 읽어 빨간 투영선(slope-projected)으로 낸다. 스케치는 평면에 그대로 남는다. 투영선이 격자 모서리에 앉도록 구조물 점유 구간 경계를 코리도 행으로 끼워 넣는다. - Plan: slope-projected 소스 추가. 리본을 받아 `측|누가거리` 색인을 만들고 그 행의 열 폴리라인에서 z를 읽는다. 절토·성토 모두 대상. 밑에 면이 없으면 점을 버려 끊는다. - Plan: 파란 스케치 안쪽 기준을 소유 측점 노견 편거리 → **그 행 절·성토면이 실제로 시작하는 자리**로 바꿨다. 종전에는 노폭 연장·측구를 못 따라가 커브가 면 밖(도로 위)에 얹혔다 — 275.71 좌측은 노견 4m 확장분만큼 안쪽이었다. - Plan: 비탈 리본 열 순서가 좌우 반대(조각은 편거리 오름차순, +offset=좌측)라 우측에서 안쪽 끝을 반대로 집었다. 색인 단계에서 안쪽→바깥으로 방향을 통일한다. - Mesh: 커브를 점의 z로 그린다(평면 커브는 z에 planZ가 들어 있어 동작 불변). 투영선 색 = 빨강 0xff3b30, 서피스에서 0.02m 띄운다. - Build: 구조물 구간 [from, to]를 행 경계로 강제 삽입. 안쪽 블록 걸음은 상수로 뺐다 (지금은 바깥과 같은 0.2m — 경계 행만 새로 생긴다). - Corridor: 저장본은 입력 해시로만 무효화되어 기하 코드를 고쳐도 옛 저장본이 계속 복원된다. ENVELOPE_VERSION을 올리고 그 함정을 주석으로 박았다. - Viewer: 검증용 __corridorBuild 훅(기존 __corridorClip과 같은 용도). 검증(공용 브라우저 캐시 데이터 실측): - 3D 회귀 없음 — 외곽선 행 1756→1768, 기존 1756행 좌표까지 전부 불변, 추가된 12행은 구조물 구간 경계뿐(79.3·83.3·85.3·89.3·144.73·154.73·199.72·202.12·270.71· 274.71·276.71·280.71). 측구·절토 리본 행 수 불변. - 투영선 안쪽 끝 8벌 전부 서피스 시작점과 XY·Z 정확 일치(gap 0, dz 0). - 바깥 끝은 XY 일치, z만 최대 1.01m 차 — 세월교 날개벽·다단 기슭막이 앞면처럼 거의 수직인 면이라 한 XY에 z가 여럿인 구간이다. 규칙 확정은 후속. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 13 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 71 +++++++- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 8 +- B05_Profile/B05_Profile_UI_Corridor_Plan.ts | 173 ++++++++++++++++++- B05_Profile/B05_Profile_UI_Viewer.ts | 28 +++ 5 files changed, 282 insertions(+), 11 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 5c4aca03..8f6d832a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -29,8 +29,17 @@ export interface CorridorStructureHook { applies: (chainageStart: number, chainageEnd: number) => boolean; } -/** 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다. */ -const ENVELOPE_VERSION = 2; +/** + * 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다. + * + * ⚠ 저장본은 **입력 해시(측점·노선)로만** 무효화된다. 코리도 기하 코드를 고쳐도 + * 입력이 그대로면 옛 저장본이 계속 복원되어 **수정이 화면에 전혀 안 나타난다** + * (2026-08-26 반나절 허비). 기하를 건드렸으면 이 번호를 반드시 함께 올릴 것. + * + * 3 = 탑뷰 투영선(slope-projected) + 구조물 구간 경계 행 + 파란 스케치 안쪽 기준을 + * 절·성토면 시작점으로 정정(노견 확장·측구 반영) — 2026-08-26. + */ +const ENVELOPE_VERSION = 3; interface CorridorEnvelope { version: number; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 03f41002..d0c96f56 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -46,6 +46,7 @@ import { } from "./B05_Profile_UI_Corridor_Structures"; export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve"; +import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; import { buildPlanCurves, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; @@ -103,6 +104,68 @@ const CARVE_APPLY: boolean = false; * 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */ const SUBDIVIDE_STEP_M = 0.2; +/** + * 투영 커브 **안쪽 블록**의 종방향 걸음(m) — 2026-08-26 사용자 B안. + * 지금은 바깥과 같은 0.2m라 경계 행만 새로 끼워 넣는 셈이다. 더 촘촘한 A안(0.1m)으로 + * 바꾸려면 이 값만 내리면 된다(사용자: "보고 판단, A로 바꿀 수도 있다"). + */ +const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M; + +/** + * 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영 + * 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은 + * 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시 + * 셀 중간에 뜬다. + */ +function structureSpanBounds(crossSections: ReadonlyArray): Array<[number, number]> { + const spans: Array<[number, number]> = []; + for (const section of crossSections) { + for (const silhouette of structureSilhouettes(section)) { + spans.push([ + section.chainage_m - silhouette.span.beforeM, + section.chainage_m + silhouette.span.afterM, + ]); + } + } + return spans; +} + +/** + * 한 측점 구간(s0~s1)의 행 자리(0~1). 등간격 걸음에 **구조물 구간 경계**를 끼워 + * 넣고 정렬·중복 제거한다. 비-마지막 구간은 t=1을 빼는 현행 규칙을 그대로 지킨다 + * (다음 구간의 t=0과 겹치면 두께 0 행이 생긴다). + */ +function rowFractions( + startChainageM: number, + spanM: number, + steps: number, + includeEnd: boolean, + spans: ReadonlyArray<[number, number]>, +): number[] { + const ts: number[] = []; + for (let k = 0; k <= (includeEnd ? steps : steps - 1); k += 1) ts.push(k / steps); + const endChainageM = startChainageM + spanM; + const insideStep = Math.max(0.01, STRUCTURE_SPAN_STEP_M); + const put = (chainageM: number): void => { + const t = (chainageM - startChainageM) / spanM; + if (t > 1e-9 && t < 1 - 1e-9) ts.push(t); + }; + for (const [from, to] of spans) { + if (to <= startChainageM || from >= endChainageM) continue; + put(from); + put(to); + // 안쪽 블록만 자기 걸음으로 다시 나눈다. 바깥과 같은 걸음(B안)이면 아래 중복 + // 제거가 전부 걸러 내 정점이 늘지 않는다 — 경계 행만 새로 생긴다. + if (insideStep < SUBDIVIDE_STEP_M - 1e-9) { + for (let at = from + insideStep; at < to - 1e-9; at += insideStep) put(at); + } + } + ts.sort((a, b) => a - b); + // 1mm보다 가까운 행은 합친다 — 겹친 행은 넓이 0 삼각형만 만든다. + const epsilon = spanM > 1e-9 ? 0.001 / spanM : 1; + return ts.filter((t, index) => index === 0 || t - ts[index - 1] > epsilon); +} + /** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */ /** 종단 보정량 — 도로부(차도·노견·측구)는 계획고에 붙어 있으므로 전량 가산한다. * 비탈은 상대 좌표라 행 앵커를 통해 보정이 실리고, 끝은 지반 트림이 정한다. */ @@ -221,6 +284,8 @@ export function buildCorridor( }; }; const structures = buildCorridorStructures(crossSections, frameAt); + // 탑뷰 투영 커브가 격자 모서리에 앉도록 구조물 구간 경계를 행으로 끼워 넣는다. + const spanBounds = structureSpanBounds(crossSections); for (let i = 0; i < stations.length - 1; i += 1) { const s0 = stations[i]; @@ -235,8 +300,7 @@ export function buildCorridor( const crossing = crossingAt?.(s0.chainage_m, s1.chainage_m) ?? null; const tSwitch = crossing === null ? 0.5 : Math.min(0.98, Math.max(0.02, (crossing - s0.chainage_m) / span)); - for (let k = 0; k <= (last ? steps : steps - 1); k += 1) { - const t = k / steps; + for (const t of rowFractions(s0.chainage_m, span, steps, last, spanBounds)) { const chainage = s0.chainage_m + span * t; const center = t === 0 @@ -545,6 +609,7 @@ export function buildCorridor( structures, // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. - planCurves: buildPlanCurves(crossSections, structures, frameAt, outline), + // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. + planCurves: buildPlanCurves(crossSections, structures, frameAt, outline, ribbons), }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index bb1bab1a..6a1d6a12 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -42,6 +42,8 @@ const PLAN_CURVE_COLORS: Record = { "slope-original": 0x38bdf8, // 날개벽 성토끝 박스 — 구조물 노랑과 구분되게 주황(2026-08-26 사용자). "wing-box": 0xff8c1a, + // 스케치 파란 커브를 절·성토면에 얹은 투영선 — 빨강(2026-08-26 사용자 지정). + "slope-projected": 0xff3b30, }; /** 유출측 파선 눈금(m) — 유입은 실선이다. */ @@ -369,8 +371,10 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou curve.loops.forEach((loop, index) => { if (loop.length < 2) return; const positions: number[] = []; - for (const [x, y] of loop) { - positions.push(x - origin.cx, curve.planZ - origin.cz, -(y - origin.cy)); + // 점의 z를 그대로 쓴다 — 평면 커브는 z에 planZ가 들어 있어 동작이 같고, + // 투영선(`slope-projected`)만 점마다 절·성토면 표고를 들고 온다(2026-08-26). + for (const [x, y, z] of loop) { + positions.push(x - origin.cx, z - origin.cz, -(y - origin.cy)); } const geometry = new THREE.BufferGeometry().setAttribute( "position", diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index 336debfc..77d76fea 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -28,7 +28,14 @@ import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Statio import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; import type { PXY } from "./B05_Profile_UI_Corridor_Union"; -export type PlanCurveSource = "structure" | "slope-deformed" | "slope-original" | "wing-box"; +export type PlanCurveSource = + | "structure" + | "slope-deformed" + | "slope-original" + | "wing-box" + /** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선** + * (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */ + | "slope-projected"; /** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */ export interface PlanCurve { @@ -162,6 +169,137 @@ function apart(a: PXY, b: PXY): boolean { return Math.hypot(b.x - a.x, b.y - a.y) > MIN_WIDTH_M; } +/** 절·성토면 조회 묶음 — 표고와 **면이 시작하는 안쪽 끝**을 같은 색인에서 낸다. */ +interface SurfaceLookup { + /** 그 행·그 측 서피스의 z. 밑에 면이 없으면 null. */ + zAt: (chainageM: number, side: Side, x: number, y: number) => number | null; + /** + * 그 행·그 측 서피스가 **실제로 시작하는 안쪽 끝**(노견 확장·측구를 이미 반영한 + * 자리). 파란 스케치의 안쪽 기준을 여기에 맞춰야 커브가 면 경계와 겹친다 + * (2026-08-26 사용자 지적: 노견 확장 개념 미반영). + */ + innerAt: (chainageM: number, side: Side, nearX: number, nearY: number) => PXY | null; +} + +function buildSurfaceZAt(ribbons: ReadonlyArray): SurfaceLookup { + /** + * `측(side)|누가거리` → 그 행의 [x, y, z] 열 목록. 절·성토만 담고, 언제나 + * **안쪽(도로) → 바깥(끝선)** 순으로 뒤집어 담는다. + * + * 비탈 조각은 편거리 오름차순으로 저장된다. 좌표 규약이 `+offset = 좌측`이라 + * 좌측은 안쪽→바깥이지만 **우측은 바깥→안쪽**이 된다(2026-08-26 실측, 세월교 + * 149.73 우측: 0열이 차도 끝에서 5.97m, 마지막 열이 0m). 방향을 여기서 한 번 + * 맞춰 두지 않으면 안쪽 끝 조회도, 수직면 무승부 처리도 우측에서 뒤집힌다. + */ + const rowsAt = new Map>>(); + for (const ribbon of ribbons) { + if (ribbon.kind !== "cut" && ribbon.kind !== "fill") continue; + ribbon.chainages.forEach((chainageM, row) => { + const cols: Array<[number, number, number]> = []; + for (let col = 0; col < ribbon.colCount; col += 1) { + const i = (row * ribbon.colCount + col) * 3; + cols.push([ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]); + } + if (ribbon.side === "right") cols.reverse(); + const key = `${ribbon.side}|${chainageM}`; + const bucket = rowsAt.get(key); + if (bucket) bucket.push(cols); + else rowsAt.set(key, [cols]); + }); + } + /** + * 폴리라인 위 XY 최근접점의 z. 폴리라인 밖이면 양 끝으로 클램프된다. + * + * 단면에는 **거의 수직인 면**(다단 기슭막이 벽 앞면)이 있어 같은 XY에 z가 여러 개 + * 걸린다 — 탑뷰 투영이 원래 모호한 자리다. 거리가 같으면 **바깥 열이 이기게** 한다: + * 열은 안쪽→바깥 순이고, 커브 바깥 끝이 뜻하는 자리는 벽 상단이 아니라 **성토 끝선** + * 이다(2026-08-26 실측: 275.71 좌측에서 끝선이 0.69m 떠 있었다). + */ + const zOnRow = ( + cols: Array<[number, number, number]>, + x: number, + y: number, + ): { distance: number; z: number } | null => { + let best: { distance: number; z: number } | null = null; + for (let i = 1; i < cols.length; i += 1) { + const [ax, ay, az] = cols[i - 1]; + const [bx, by, bz] = cols[i]; + const dx = bx - ax; + const dy = by - ay; + const lengthSq = dx * dx + dy * dy; + const t = + lengthSq > 1e-12 ? Math.min(1, Math.max(0, ((x - ax) * dx + (y - ay) * dy) / lengthSq)) : 0; + const px = ax + dx * t; + const py = ay + dy * t; + const distance = Math.hypot(x - px, y - py); + const z = az + (bz - az) * t; + if (!best || distance < best.distance - 1e-6) best = { distance, z }; + // 무승부(수직면) — 뒤에 오는 바깥 열로 갈아탄다. 최소 거리는 유지한다. + else if (distance < best.distance + 1e-6) + best = { distance: Math.min(distance, best.distance), z }; + } + return best; + }; + return { + zAt: (chainageM, side, x, y) => { + const buckets = rowsAt.get(`${side}|${chainageM}`); + if (!buckets) return null; + let best: { distance: number; z: number } | null = null; + for (const cols of buckets) { + // 정점에 얹힌 XY(면 안쪽 끝·바깥 끝)는 **그 정점 z를 그대로** 쓴다 — 보간· + // 최근접 판정을 태우면 수직면(다단 벽 앞면)에서 엉뚱한 단이 이긴다. 허용오차는 + // 1mm — 1e-6으로 잡았더니 끝점이 0.5mm 어긋난 행에서 최대 1.01m 튀었다 + // (2026-08-26 실측, 세월교 149.73). 열은 안쪽→바깥이라 무승부면 바깥이 이긴다. + let exact: { distance: number; z: number } | null = null; + for (const [px, py, pz] of cols) { + const distance = Math.hypot(x - px, y - py); + if (distance > VERTEX_SNAP_M) continue; + if (!exact || distance < exact.distance + 1e-9) exact = { distance, z: pz }; + } + const hit = exact ?? zOnRow(cols, x, y); + if (!hit) continue; + if (!best || hit.distance < best.distance) best = hit; + } + return best ? best.z : null; + }, + innerAt: (chainageM, side, nearX, nearY) => { + const buckets = rowsAt.get(`${side}|${chainageM}`); + if (!buckets) return null; + // 한 행에 절·성토가 같이 있으면 **도로에 가까운 쪽**의 안쪽 끝을 쓴다. + let best: { distance: number; point: PXY } | null = null; + for (const cols of buckets) { + const [x, y] = cols[0]; + const distance = Math.hypot(nearX - x, nearY - y); + if (!best || distance < best.distance) best = { distance, point: { x, y } }; + } + return best ? best.point : null; + }, + }; +} + +/** 표고 조회에 필요한 리본 최소 형태 — Build의 `CorridorRibbon`이 그대로 들어맞는다. */ +export interface CorridorRibbonLike { + kind: string; + /** "left" | "right" | "center" — 비탈 리본은 좌·우뿐이라 center는 걸러진다. */ + side: string; + colCount: number; + chainages: ReadonlyArray; + positions: ArrayLike; +} + +/** 투영 점을 리본 정점으로 붙여 보는 허용오차(m) — 수직면에서 단이 뒤바뀌는 것 방지. */ +const VERTEX_SNAP_M = 0.001; + +/** 투영선을 서피스에서 띄우는 높이(m) — z-fighting 방지. */ +const PROJECT_LIFT_M = 0.02; + +/** 스트립 행을 **점마다 다른 표고**로 얹어 닫힌 고리로 만든다(투영선용). */ +function projectedLoops( + rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>, +): Array> { + return assembleStripLoops(rows); +} + /** * 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음 * (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌). @@ -171,9 +309,12 @@ export function buildPlanCurves( structures: ReadonlyArray, frameAt: (chainageM: number) => RouteFrame | null, outline: CorridorOutline, + /** 절·성토 리본 — 투영선 표고를 여기서 읽는다. 없으면 투영선을 안 낸다. */ + ribbons: ReadonlyArray = [], ): PlanCurve[] { const curves: PlanCurve[] = []; const groundAt = buildGroundAt(crossSections); + const surface = buildSurfaceZAt(ribbons); for (const section of crossSections) { const set = structures.filter( (structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6, @@ -280,21 +421,28 @@ export function buildPlanCurves( return [at(frame, inner), at(frame, outer)]; }); push("slope-deformed", side, stripLoops(deformedRows, planZ)); - // 원본 성토·절토선 — [노견 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다. + // 원본 성토·절토선 — [면 안쪽 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다. + // 안쪽 끝은 **그 행 절·성토면이 실제로 시작하는 자리**를 쓴다 — 소유 측점 노견 + // 편거리를 그대로 쓰면 노폭 연장·측구를 못 따라가 커브가 면 밖(도로 위)에 얹힌다 + // (2026-08-26 사용자 지적. 275.71 좌측 실측: 노견 확장 4m만큼 안쪽으로 들어가 있었다). const rows: Array<[PXY, PXY] | null> = []; // BOX암거는 가로 방향이 고정이라 그 행의 윤곽 점을 그대로 쓰면 방향이 틀어진다. // 대신 고정 방향으로 쏜 선이 성토 끝선과 만나는 자리를 바깥 끝으로 삼는다 — // 끝은 여전히 실제 성토 끝선 위다(2026-08-26 사용자). const boxToe = boxAxis ? nearToe(outline, side, section.chainage_m) : null; + /** 같은 행을 절·성토면 표고에 얹은 것 — 빨간 투영선(2026-08-26 사용자). */ + const projected: Array<[[number, number, number], [number, number, number]] | null> = []; outline.chainages.forEach((chainageM, index) => { if (chainageM < from - 1e-9 || chainageM > to + 1e-9) return; const frame = frameOf(chainageM); - const innerXY = at(frame, roadEdge); + const edgeXY = at(frame, roadEdge); + const surfaceStart = surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y); let outerXY: PXY; if (boxToe) { const hit = castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxToe); if (!hit) { rows.push(null); + projected.push(null); return; } outerXY = { x: hit.x, y: hit.y }; @@ -302,9 +450,26 @@ export function buildPlanCurves( const [ox, oy] = side === "left" ? outline.left[index] : outline.right[index]; outerXY = { x: ox, y: oy }; } - rows.push(apart(innerXY, outerXY) ? [innerXY, outerXY] : null); + // 면 시작점이 바깥 끝과 붙어 버리는 행(그 측 비탈이 거의 없는 자리)에서는 + // 노견 편거리로 되돌린다 — 안 그러면 그 측 커브가 통째로 사라진다. + const innerXY = surfaceStart && apart(surfaceStart, outerXY) ? surfaceStart : edgeXY; + const keep = apart(innerXY, outerXY); + rows.push(keep ? [innerXY, outerXY] : null); + // 서피스 표고를 못 읽는 자리(밑에 절·성토 리본이 없는 행)는 선을 끊는다 — + // 없는 면에 억지로 붙이지 않는다. + const innerZ = keep ? surface.zAt(chainageM, side, innerXY.x, innerXY.y) : null; + const outerZ = keep ? surface.zAt(chainageM, side, outerXY.x, outerXY.y) : null; + projected.push( + innerZ === null || outerZ === null + ? null + : [ + [innerXY.x, innerXY.y, innerZ + PROJECT_LIFT_M], + [outerXY.x, outerXY.y, outerZ + PROJECT_LIFT_M], + ], + ); }); push("slope-original", side, stripLoops(rows, planZ)); + push("slope-projected", side, projectedLoops(projected)); } } return curves; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index f765ccf8..9016a33a 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -590,6 +590,34 @@ export function createRouteViewer(): RouteViewer { function setCorridor(build: CorridorBuildResult | null): void { disposeCorridorGroup(); corridorBuild = build; + // 검증용 요약(2026-08-26) — 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 + // 수치로 확인한다(`__corridorClip`과 같은 용도). + (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build + ? { + ribbons: build.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + rows: ribbon.chainages.length, + cols: ribbon.colCount, + first: ribbon.chainages[0], + last: ribbon.chainages[ribbon.chainages.length - 1], + })), + outlineRows: build.outline.chainages.length, + outline: build.outline, + // 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. + raw: { ribbons: build.ribbons, planCurves: build.planCurves ?? [] }, + planCurves: (build.planCurves ?? []).map((curve) => ({ + source: curve.source, + role: curve.role, + side: curve.side, + at: curve.setChainageM, + loops: curve.loops.length, + points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), + z0: curve.loops[0]?.[0]?.[2] ?? null, + planZ: curve.planZ, + })), + } + : null; if (build && bounds) { snapCorridorEdges(build); corridorGroup = createCorridorGroup(build, bounds);