feat(B05): 탑뷰 스케치 파란 커브를 절·성토면에 투영해 빨간 선으로 얹는다
구조물 상단 +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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<CrossSection>): 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
|
||||
"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",
|
||||
|
||||
@@ -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<CorridorRibbonLike>): SurfaceLookup {
|
||||
/**
|
||||
* `측(side)|누가거리` → 그 행의 [x, y, z] 열 목록. 절·성토만 담고, 언제나
|
||||
* **안쪽(도로) → 바깥(끝선)** 순으로 뒤집어 담는다.
|
||||
*
|
||||
* 비탈 조각은 편거리 오름차순으로 저장된다. 좌표 규약이 `+offset = 좌측`이라
|
||||
* 좌측은 안쪽→바깥이지만 **우측은 바깥→안쪽**이 된다(2026-08-26 실측, 세월교
|
||||
* 149.73 우측: 0열이 차도 끝에서 5.97m, 마지막 열이 0m). 방향을 여기서 한 번
|
||||
* 맞춰 두지 않으면 안쪽 끝 조회도, 수직면 무승부 처리도 우측에서 뒤집힌다.
|
||||
*/
|
||||
const rowsAt = new Map<string, Array<Array<[number, number, number]>>>();
|
||||
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<number>;
|
||||
positions: ArrayLike<number>;
|
||||
}
|
||||
|
||||
/** 투영 점을 리본 정점으로 붙여 보는 허용오차(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<Array<[number, number, number]>> {
|
||||
return assembleStripLoops(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음
|
||||
* (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌).
|
||||
@@ -171,9 +309,12 @@ export function buildPlanCurves(
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
outline: CorridorOutline,
|
||||
/** 절·성토 리본 — 투영선 표고를 여기서 읽는다. 없으면 투영선을 안 낸다. */
|
||||
ribbons: ReadonlyArray<CorridorRibbonLike> = [],
|
||||
): 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user