From 01c2a5983bb95fc452cf3d735dc07e985edd1f87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 20:14:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=203D=20=EC=98=88=EC=83=81=ED=98=95?= =?UTF-8?q?=EC=83=81=20=EC=83=88=20=EB=B0=A9=EC=8B=9D=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EB=A9=94=EC=8B=9C=20=EB=86=92=EC=9D=B4?= =?UTF-8?q?=EB=A7=8C=20=EC=84=A4=EA=B3=84=EB=A9=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B0=EB=8A=94=20=EB=B3=80=ED=98=95=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4=20=C2=B7=20=EC=83=88/=EC=98=9B=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94(115-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정점 → (행 · 편거리) 역투영(행 중심 격자 + 이웃 행 걷기) · z' = clamp(지형, 성토선, 절토선) - 비탈 끝(첫 지반 교차점) 밖은 원지반 · 단면 밖 마지막 구배 연장 · 측점 사이 제어점 보간 - 노면부 리본 유지 · 지형은 노면부 끝에서 자르고 경계 z = 앵커 z · 사본에만 · XY 고정 - 색 = 높이 변화 부호(절토 · 성토 색 · 빗금 없음 · 흑백 지형에서도 유지) - 3D 보기 줄 「새 방식」 단추(기본 켬 · pref 등록) — 옛 방식 코드는 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/b_page_state.ts | 2 + B05_Profile/B05_Profile_UI_Corridor.ts | 13 +- .../B05_Profile_UI_Corridor_Build_Types.ts | 3 + B05_Profile/B05_Profile_UI_Corridor_Deform.ts | 385 ++++++++++++++++++ .../B05_Profile_UI_Corridor_Deform_Terrain.ts | 156 +++++++ B05_Profile/B05_Profile_UI_Panel_View.ts | 3 + B05_Profile/B05_Profile_UI_Viewer.ts | 3 + B05_Profile/B05_Profile_UI_Viewer_Corridor.ts | 49 ++- .../tester/helper_115_corridor_deform.cjs | 246 +++++++++++ resources/tester/test_115_corridor_deform.py | 76 ++++ 10 files changed, 930 insertions(+), 6 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Deform.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts create mode 100644 resources/tester/helper_115_corridor_deform.cjs create mode 100644 resources/tester/test_115_corridor_deform.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index ae28bb75c..d67cd3382 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -66,6 +66,8 @@ export const STATE_REGISTRY = { "m02-card-pills": { bucket: "pref", scope: "global" }, /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ "m01-logic-panel": { bucket: "pref", scope: "global" }, + /** B05 3D 예상형상 그리기 — "1" 새 방식(지형 변형 · 기본) · "0" 옛 방식(115-1 비교용) */ + "corridor-deform": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 7ce3ea850..94b2b27a3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -236,6 +236,15 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P await writeCachedBytes(projectId, url, bytes); } +/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */ +function withSections( + build: CorridorBuildResult | null, + detail: SectionDetailResponse, +): CorridorBuildResult | null { + if (build) build.sections = detail.cross_sections; + return build; +} + /** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ export function refreshCorridor( @@ -249,7 +258,7 @@ export function refreshCorridor( ): Promise { if (!routeId) return Promise.resolve(); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) - .then((build) => viewer.setCorridor(build)) + .then((build) => viewer.setCorridor(withSections(build, detail))) .catch(() => viewer.setCorridor(null)); } @@ -269,7 +278,7 @@ export function loadCorridorIfFresh( if (!routeId) return Promise.resolve(false); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) .then((build) => { - if (build) viewer.setCorridor(build); + if (build) viewer.setCorridor(withSections(build, detail)); return build !== null; }) .catch(() => false); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts b/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts index dce9f83fc..9dcf752d7 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts @@ -7,6 +7,7 @@ * (`... from "./B05_Profile_UI_Corridor_Build"`)는 전부 유효하다. * ========================================================================== */ +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station"; import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; @@ -96,4 +97,6 @@ export interface CorridorBuildResult { planCurves?: PlanCurve[]; /** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */ cutWalls?: CorridorCutWall[]; + /** 측점 단면 — 새 방식 지형 변형이 비탈 단면을 읽는다(115-1). 메모리만 · 저장본에 안 담는다. */ + sections?: CrossSection[]; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts new file mode 100644 index 000000000..38170e5ef --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts @@ -0,0 +1,385 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Deform.ts + * 3D 예상형상 **새 방식** — 지형 메시 정점의 높이(z)만 설계면으로 옮기는 변형 코어 + * (계획서 115-1 · 검토 `docs/raw/요구_1008_4/검토_B05_3D_메시변형.md` 3절). + * + * 순수 기하 — Three.js 없음. 지형 높이는 부르는 쪽이 `groundAt`(모델 좌표)으로 준다. + * + * 규칙: + * · 행 = 코리도 빌드의 노면부 리본(차도 · 노견 · 측구) 행. 노면부 바깥 끝 = 앵커. + * · 정점 → (행 · 편거리) 역투영 — 0.2 m 행 중심 격자 색인 + 이웃 행 걷기. + * · 노면부 바깥에서 `z' = clamp(지형 z, 성토선 z, 절토선 z)`. 선 = 앵커 + 측점 사이 + * 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장. + * · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반. + * · XY 는 움직이지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; +import { + classifyStation, + pieceKey, + type OffsetPoint, + type StationPieces, +} from "./B05_Profile_UI_Corridor_Station"; +import { + bracketControls, + buildPieceControls, + lerpPoints, + type PieceControl, +} from "./B05_Profile_UI_Corridor_Frames"; + +export type DeformSide = "left" | "right"; +export type DeformKind = "cut" | "fill"; + +/** 비탈 끝 찾기 한계(m) · 걸음(m) — 밴드 여유(80 m) 안쪽. */ +export const MAX_REACH_M = 60; +const SCAN_STEP_M = 0.25; +/** 이 안쪽 정점은 앵커 높이 그대로(노면부 끝 경계 정점). */ +const BOUNDARY_EPS_M = 0.01; +/** 역투영 되짚기 허용 오차(m) — 넘으면 곡선 안쪽 모호 자리로 보고 손대지 않는다. */ +const LOCATE_TOLERANCE_M = 0.5; +/** 행 중심 격자 칸(m) · 씨앗 행 간격(행 수). */ +const GRID_M = 4; +const SEED_EVERY = 5; +const WALK_LIMIT = 400; + +const SIDES: readonly DeformSide[] = ["left", "right"]; +const KINDS: readonly DeformKind[] = ["fill", "cut"]; + +/** 노면부 바깥 끝 앵커 한 행 — 모델 좌표 [x, y, z]. */ +export interface DeformRow { + chainage_m: number; + left: [number, number, number]; + right: [number, number, number]; +} + +/** 비탈 단면(앵커 기준) — 바깥거리 s ≥ 0 오름차순 [s0, dz0, s1, dz1, …]. */ +export type DeformProfile = Float64Array; + +/** 한 행 · 한 측의 단면 출처 — 구조물 구간 고정 단면이면 연장 · 반대 종류를 막는다. */ +export interface RowProfiles { + fill: DeformProfile | null; + cut: DeformProfile | null; + /** 단면 끝 너머로 늘리지 않는다(BOX 「본체 밖 원지반」). */ + noExtend?: boolean; +} + +/** 행 · 측마다 단면을 내주는 자리 — 115-2 구조물 단면이 이 자리를 갈아 끼운다. */ +export type ProfileSource = (chainageM: number, side: DeformSide) => RowProfiles; + +/** 정점 하나의 역투영 결과. */ +export interface DeformLocation { + row: number; + t: number; + side: DeformSide; + /** 노면부 바깥 끝에서 잰 바깥거리(m) — 음수면 노면부 안. */ + outward: number; +} + +export interface DeformField { + rows: DeformRow[]; + locate: (x: number, y: number) => DeformLocation | null; + /** 변형된 z — 대상이 아니면 받은 z 그대로. */ + deformZ: (x: number, y: number, z: number) => number; +} + +/** 단면 높이 — s 가 끝을 넘으면 마지막 구배로 늘린다(`noExtend`면 null). */ +export function profileZ(profile: DeformProfile, s: number, noExtend = false): number | null { + const count = profile.length / 2; + if (count < 1) return null; + if (s <= profile[0]) return profile[1]; + for (let i = 1; i < count; i += 1) { + const s1 = profile[i * 2]; + if (s <= s1) { + const s0 = profile[i * 2 - 2]; + const span = s1 - s0; + const t = span <= 1e-9 ? 1 : (s - s0) / span; + return profile[i * 2 - 1] + (profile[i * 2 + 1] - profile[i * 2 - 1]) * t; + } + } + if (noExtend) return null; + // 마지막 구배 — 끝에서 거꾸로 짚어 길이가 있는 마디를 쓴다(`_Station.ts` trimSlopeToGround 연장과 같은 규칙). + const last = count - 1; + for (let i = last - 1; i >= 0; i -= 1) { + const run = profile[last * 2] - profile[i * 2]; + if (run > 1e-3) { + const grade = (profile[last * 2 + 1] - profile[i * 2 + 1]) / run; + return profile[last * 2 + 1] + grade * (s - profile[last * 2]); + } + } + return profile[last * 2 + 1]; +} + +/** 상대 단면 점(편거리 · 표고, 도로측 원점) → 바깥거리 오름차순 단면. */ +export function outwardProfile( + points: ReadonlyArray, + side: DeformSide, +): DeformProfile | null { + if (points.length < 2) return null; + const sign = side === "left" ? 1 : -1; + const ordered = side === "left" ? points : [...points].reverse(); + const origin = ordered[0]; + const profile = new Float64Array(ordered.length * 2); + ordered.forEach((point, i) => { + profile[i * 2] = Math.max(0, (point.offset_m - origin.offset_m) * sign); + profile[i * 2 + 1] = point.elevation_m - origin.elevation_m; + }); + return profile; +} + +/** 빌드 노면부 리본에서 행마다 좌우 바깥 끝(측구 > 노견 > 차도)을 모은다. */ +export function roadRows(build: CorridorBuildResult): DeformRow[] { + const RANK: Record = { carriageway: 1, shoulder: 2, ditch: 3 }; + const byKey = new Map< + number, + { + chainage: number; + left?: DeformRow["left"]; + lr: number; + right?: DeformRow["right"]; + rr: number; + } + >(); + for (const ribbon of build.ribbons) { + const rank = RANK[ribbon.kind]; + if (!rank || ribbon.patch) continue; + const cols = ribbon.colCount; + ribbon.chainages.forEach((chainage, row) => { + const key = Math.round(chainage * 1000); + const entry = byKey.get(key) ?? { chainage, lr: 0, rr: 0 }; + const at = (col: number): [number, number, number] => { + const i = (row * cols + col) * 3; + return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]; + }; + if (ribbon.side !== "right" && rank > entry.lr) { + entry.left = at(cols - 1); + entry.lr = rank; + } + if (ribbon.side !== "left" && rank > entry.rr) { + entry.right = at(0); + entry.rr = rank; + } + byKey.set(key, entry); + }); + } + return [...byKey.entries()] + .sort((a, b) => a[0] - b[0]) + .flatMap(([, entry]) => + entry.left && entry.right + ? [{ chainage_m: entry.chainage, left: entry.left, right: entry.right }] + : [], + ); +} + +/** 측점 비탈 단면으로 내는 기본 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). */ +export function stationProfileSource(sections: ReadonlyArray): ProfileSource { + const stations = sections + .map(classifyStation) + .filter((s): s is StationPieces => s !== null) + .sort((a, b) => a.chainage_m - b.chainage_m); + const controls = new Map(); + SIDES.forEach((side) => + KINDS.forEach((kind) => { + const key = pieceKey(kind, side); + controls.set( + key, + buildPieceControls(stations, side, (station) => station.slopeRelative.get(key)), + ); + }), + ); + const at = (side: DeformSide, kind: DeformKind, chainageM: number): DeformProfile | null => { + // 그 측에 그 종류가 한 번도 없으면 반대 측 단면을 빌린다 — 없으면 앵커 옆이 한 삼각형 비탈로 떨어진다. + const other: DeformSide = side === "left" ? "right" : "left"; + const own = controls.get(pieceKey(kind, side)); + const list = own ?? controls.get(pieceKey(kind, other)); + if (!list) return null; + const { a, b, t } = bracketControls(list, chainageM); + const points = a === b ? a.points : lerpPoints(a.points, b.points, t); + return outwardProfile(points, own ? side : other); + }; + return (chainageM, side) => ({ + fill: at(side, "fill", chainageM), + cut: at(side, "cut", chainageM), + }); +} + +/** + * 변형 장 만들기. 노면부 행이 2개 미만이면 null. + * `groundAt` = 원지반 높이(모델 좌표) — 비탈 끝(첫 지반 교차점) 찾기에만 쓴다. + */ +export function buildDeformField( + build: CorridorBuildResult, + source: ProfileSource, + groundAt: (x: number, y: number) => number | null, +): DeformField | null { + const rows = roadRows(build); + const n = rows.length; + if (n < 2) return null; + const mx = new Float64Array(n); + const my = new Float64Array(n); + const ux = new Float64Array(n); + const uy = new Float64Array(n); + const hw = new Float64Array(n); + /** [행 × 측 × 종류] 단면 · 비탈 끝 거리. 측 = 0 좌 · 1 우, 종류 = 0 성토 · 1 절토. */ + const profiles: Array = new Array(n * 4).fill(null); + const catches = new Float64Array(n * 4); + const noExtend = new Uint8Array(n * 2); + let reachMax = 0; + rows.forEach((row, r) => { + const dx = row.left[0] - row.right[0]; + const dy = row.left[1] - row.right[1]; + const width = Math.hypot(dx, dy) || 1e-9; + mx[r] = (row.left[0] + row.right[0]) / 2; + my[r] = (row.left[1] + row.right[1]) / 2; + ux[r] = dx / width; + uy[r] = dy / width; + hw[r] = width / 2; + SIDES.forEach((side, si) => { + const anchor = side === "left" ? row.left : row.right; + const sign = side === "left" ? 1 : -1; + const shapes = source(row.chainage_m, side); + noExtend[r * 2 + si] = shapes.noExtend ? 1 : 0; + KINDS.forEach((kind, ki) => { + const profile = shapes[kind]; + profiles[r * 4 + si * 2 + ki] = profile; + if (!profile) return; + // 비탈 끝 = 앵커에서 바깥으로 걸어 지반과 처음 만나는 자리. 성토는 지반이 선 위로, + // 절토는 아래로 넘어가는 곳. 앵커에서 이미 반대편이면 그 종류는 이 행에 없다(0). + const beyond = (s: number): number | null => { + const line = profileZ(profile, s, shapes.noExtend); + if (line === null) return null; + const ground = groundAt(anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s); + if (ground === null) return null; + const gap = anchor[2] + line - ground; + return kind === "fill" ? gap : -gap; + }; + let previous = beyond(0); + let reach = 0; + if (previous !== null && previous > 0) { + for (let s = SCAN_STEP_M; s <= MAX_REACH_M + 1e-9; s += SCAN_STEP_M) { + const now = beyond(s); + if (now === null) { + reach = s - SCAN_STEP_M; + break; + } + if (now <= 0) { + reach = s - SCAN_STEP_M + (SCAN_STEP_M * previous) / (previous - now); + break; + } + previous = now; + reach = s; + } + } + catches[r * 4 + si * 2 + ki] = reach; + if (reach > reachMax) reachMax = reach; + }); + }); + }); + + // 행 중심 격자 — 칸마다 가장 가까운 씨앗 행. 정점은 그 행에서 이웃으로 걸어 자리를 잡는다. + let maxHalf = 0; + hw.forEach((value) => (maxHalf = Math.max(maxHalf, value))); + const radius = maxHalf + reachMax + GRID_M; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let r = 0; r < n; r += 1) { + minX = Math.min(minX, mx[r]); + maxX = Math.max(maxX, mx[r]); + minY = Math.min(minY, my[r]); + maxY = Math.max(maxY, my[r]); + } + minX -= radius; + minY -= radius; + const cols = Math.max(1, Math.ceil((maxX + radius - minX) / GRID_M)); + const gridRows = Math.max(1, Math.ceil((maxY + radius - minY) / GRID_M)); + const seed = new Int32Array(cols * gridRows).fill(-1); + const seedDistance = new Float64Array(cols * gridRows).fill(Infinity); + const span = Math.ceil(radius / GRID_M); + const seeds: number[] = []; + for (let r = 0; r < n; r += SEED_EVERY) seeds.push(r); + if (seeds[seeds.length - 1] !== n - 1) seeds.push(n - 1); + for (const r of seeds) { + const c0 = Math.floor((mx[r] - minX) / GRID_M); + const g0 = Math.floor((my[r] - minY) / GRID_M); + for (let g = Math.max(0, g0 - span); g <= Math.min(gridRows - 1, g0 + span); g += 1) { + for (let c = Math.max(0, c0 - span); c <= Math.min(cols - 1, c0 + span); c += 1) { + const d = Math.hypot(minX + (c + 0.5) * GRID_M - mx[r], minY + (g + 0.5) * GRID_M - my[r]); + if (d > radius || d >= seedDistance[g * cols + c]) continue; + seedDistance[g * cols + c] = d; + seed[g * cols + c] = r; + } + } + } + + const along = (r: number, x: number, y: number): number => + (x - mx[r]) * uy[r] - (y - my[r]) * ux[r]; + const lateral = (r: number, x: number, y: number): number => + (x - mx[r]) * ux[r] + (y - my[r]) * uy[r]; + + // ponytail: 칸의 가장 가까운 씨앗 하나로 걷는다 — 헤어핀 두 가지가 한 칸에 겹치면 칸 중심에 + // 가까운 쪽(대개 편거리 작은 쪽)을 탄다. 어긋나면 씨앗 둘을 견주는 쪽으로 넓힌다. + function locate(x: number, y: number): DeformLocation | null { + const c = Math.floor((x - minX) / GRID_M); + const g = Math.floor((y - minY) / GRID_M); + if (c < 0 || g < 0 || c >= cols || g >= gridRows) return null; + let k = seed[g * cols + c]; + if (k < 0) return null; + let steps = 0; + while (k > 0 && along(k, x, y) < 0 && steps < WALK_LIMIT) { + k -= 1; + steps += 1; + } + while (k + 1 < n && along(k + 1, x, y) >= 0 && steps < WALK_LIMIT) { + k += 1; + steps += 1; + } + if (k + 1 >= n) return null; + const a0 = along(k, x, y); + const a1 = along(k + 1, x, y); + if (a0 < 0 || a1 >= 0) return null; + const t = a0 / (a0 - a1); + const o = lateral(k, x, y) * (1 - t) + lateral(k + 1, x, y) * t; + // 되짚기 — 보간 행 위 그 편거리 자리가 정점과 같아야 한다(곡선 안쪽 너머는 버린다). + const cx = mx[k] + (mx[k + 1] - mx[k]) * t; + const cy = my[k] + (my[k + 1] - my[k]) * t; + const vx = ux[k] + (ux[k + 1] - ux[k]) * t; + const vy = uy[k] + (uy[k + 1] - uy[k]) * t; + const norm = Math.hypot(vx, vy) || 1; + if (Math.hypot(cx + (vx / norm) * o - x, cy + (vy / norm) * o - y) > LOCATE_TOLERANCE_M) { + return null; + } + const half = hw[k] + (hw[k + 1] - hw[k]) * t; + return { row: k, t, side: o >= 0 ? "left" : "right", outward: Math.abs(o) - half }; + } + + /** 한 행 기준 변형 높이 — 비탈 끝 안에서만 성토선 위로 · 절토선 아래로 맞춘다. */ + function rowZ(r: number, si: number, s: number, z: number): number { + const anchor = si === 0 ? rows[r].left : rows[r].right; + if (s <= BOUNDARY_EPS_M) return anchor[2]; + let out = z; + const free = noExtend[r * 2 + si] === 1; + KINDS.forEach((kind, ki) => { + const slot = r * 4 + si * 2 + ki; + const profile = profiles[slot]; + if (!profile || s >= catches[slot]) return; + const rel = profileZ(profile, s, free); + if (rel === null) return; + const line = anchor[2] + rel; + if (kind === "fill" ? out < line : out > line) out = line; + }); + return out; + } + + function deformZ(x: number, y: number, z: number): number { + const at = locate(x, y); + if (!at || at.outward > reachMax) return z; + const si = at.side === "left" ? 0 : 1; + const s = Math.max(0, at.outward); + return rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t; + } + + return { rows, locate, deformZ }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts new file mode 100644 index 000000000..0d185451d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Deform_Terrain.ts + * 새 방식 지형 — 밴드 사본을 노면부 바깥선에서 자르고(`clipGeometry`) 사본 정점의 + * 높이만 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1). + * + * 원본 · 분할본 정점 버퍼는 건드리지 않는다 — `clipGeometry` 가 새 위치 배열을 만들고, + * 바뀐 것이 없을 땐 여기서 복제한다. [예상형상] OFF 면 뷰어가 원본을 그대로 보인다. + * 법선은 새로 만들지 않는다(감기 방향 뒤집혀 검게 된 전례 · `_Clip.ts`). + * 색 = 높이 변화 부호 — 절토 · 성토 색(코리도 리본과 같은 색), 빗금 없음. + * ========================================================================== */ + +import * as THREE from "three"; +import type { ModelBounds } from "./B05_Profile_UI_Markers"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; +import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; +import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; +import { clipGeometry } from "./B05_Profile_UI_Corridor_Clip"; +import { CellRegion, scenePlanMapper, type PlanRegion } from "./B05_Profile_UI_Corridor_Region"; +import { + buildDeformField, + stationProfileSource, + type DeformField, + type DeformRow, +} from "./B05_Profile_UI_Corridor_Deform"; + +/** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */ +const CUT_COLOR = new THREE.Color(0xb5179e); +const FILL_COLOR = new THREE.Color(0x06b6d4); +/** 이보다 작게 움직인 정점은 원지반 색 그대로(m). */ +const TINT_MIN_M = 0.01; + +/** 노면부 띠 — 행마다 좌우 앵커 사이 사각형. 지형은 이 안을 도려낸다. */ +class RoadStrip extends CellRegion { + constructor(rows: ReadonlyArray, bounds: ModelBounds) { + super(); + const toScene = scenePlanMapper(bounds); + const left = rows.map((row) => toScene(row.left[0], row.left[1])); + const right = rows.map((row) => toScene(row.right[0], row.right[1])); + for (let i = 0; i < rows.length - 1; i += 1) { + this.addCell(left[i], right[i], right[i + 1], left[i + 1]); + this.addSegment(left[i], left[i + 1]); + this.addSegment(right[i], right[i + 1]); + } + this.addSegment(left[0], right[0]); + this.addSegment(left[rows.length - 1], right[rows.length - 1]); + this.buildIndex(); + } +} + +/** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */ +export interface DeformStats { + rows: number; + moved: number; + raised: number; + lowered: number; + maxRaiseM: number; + maxCutM: number; +} + +/** + * 사본 geometry 정점 높이를 옮기고 색을 칠한다. 씬 좌표(Y-up) ↔ 모델 좌표는 뷰어 규약. + * 칠한 정점은 `userData.keepColor` 에 표시해 흑백 지형 토글이 덮지 않게 한다. + */ +function deformGeometry( + geometry: THREE.BufferGeometry, + field: DeformField, + bounds: ModelBounds, + stats: DeformStats, +): Uint8Array { + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + const position = geometry.getAttribute("position") as THREE.BufferAttribute; + const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; + const keep = new Uint8Array(position.count); + for (let v = 0; v < position.count; v += 1) { + const sceneY = position.getY(v); + const z = sceneY + oz; + const next = field.deformZ(position.getX(v) + ox, -position.getZ(v) + oy, z); + const delta = next - z; + if (Math.abs(delta) < TINT_MIN_M) continue; + position.setY(v, next - oz); + stats.moved += 1; + if (delta > 0) { + stats.raised += 1; + stats.maxRaiseM = Math.max(stats.maxRaiseM, delta); + } else { + stats.lowered += 1; + stats.maxCutM = Math.max(stats.maxCutM, -delta); + } + if (color) { + const tint = delta > 0 ? FILL_COLOR : CUT_COLOR; + color.setXYZ(v, tint.r, tint.g, tint.b); + keep[v] = 1; + } + } + position.needsUpdate = true; + if (color) color.needsUpdate = true; + geometry.computeBoundingSphere(); + geometry.computeBoundingBox(); + return keep; +} + +/** + * 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 잘라 변형한다. 변형 장을 못 세우면 + * (노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 옛 방식으로 그린다. + */ +export function deformTerrain( + split: TerrainBandSplit, + build: CorridorBuildResult, + bounds: ModelBounds, + heightIndex: TerrainHeightIndex, + extraRegions: ReadonlyArray = [], +): { root: THREE.Object3D; stats: DeformStats } | null { + if (!build.sections?.length) return null; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + const groundAt = (x: number, y: number): number | null => { + const height = heightIndex.heightAt(x - ox, -(y - oy)); + return height === null ? null : height + oz; + }; + const field = buildDeformField(build, stationProfileSource(build.sections), groundAt); + if (!field) return null; + const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds), ...extraRegions]; + const stats: DeformStats = { + rows: field.rows.length, + moved: 0, + raised: 0, + lowered: 0, + maxRaiseM: 0, + maxCutM: 0, + }; + const root = new THREE.Group(); + root.name = "terrain-deformed"; + split.near.forEach((part) => { + if (part.points) { + // 포인트클라우드는 변형 대상이 아니다 — 분할본 그대로 얹는다. + const points = new THREE.Points(part.geometry, part.material as THREE.Material); + points.userData.owned = false; + root.add(points); + return; + } + const geometry = clipGeometry(part.geometry, regions) ?? part.geometry.clone(); + const mesh = new THREE.Mesh(geometry, part.material); + mesh.userData.owned = true; + mesh.userData.keepColor = deformGeometry(geometry, field, bounds, stats); + root.add(mesh); + }); + split.far.forEach((part) => { + const mesh = new THREE.Mesh(part.geometry, part.material); + mesh.userData.owned = false; + root.add(mesh); + }); + return { root, stats }; +} diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index b09d2e0e0..c6a16c915 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -5,6 +5,7 @@ * ========================================================================== */ import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; +import { corridorDeformOn, setCorridorDeform } from "./B05_Profile_UI_Viewer_Corridor"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ export interface ViewControlCallbacks { @@ -62,6 +63,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): { toggleButton("지표면", true, callbacks.onSurfaceVisible), // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. toggleButton("예상형상", true, callbacks.onCorridorVisible), + // 예상형상 그리기(115-1) — 켬 = 새 방식(지형 메시 변형) · 끔 = 옛 방식(스케치 · 패치). + toggleButton("새 방식", corridorDeformOn(), setCorridorDeform), toggleButton("등고선", true, callbacks.onContoursVisible), toggleButton("축 표시", false, callbacks.onAxesVisible), toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 1d0d2e415..78ddd2885 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -399,8 +399,11 @@ export function createRouteViewer(): RouteViewer { if (!child.userData.originalColors) child.userData.originalColors = target.slice(); const original = child.userData.originalColors as Float32Array; const stride = color.itemSize; + // 새 방식 절성토 색(115-1)은 흑백에서도 남긴다. + const keep = child.userData.keepColor as Uint8Array | undefined; if (surfaceGrayscale) { for (let i = 0; i + 2 < original.length; i += stride) { + if (keep?.[i / stride]) continue; const gray = 0.299 * original[i] + 0.587 * original[i + 1] + 0.114 * original[i + 2]; target[i] = target[i + 1] = target[i + 2] = gray; } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts index d7492a9f3..158b4f194 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts @@ -19,6 +19,33 @@ import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; +import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; + +/** + * 3D 예상형상 그리기 방식(115-1) — 새 방식(지형 메시 변형 · 기본) / 옛 방식(스케치 · 패치). + * 사용자가 두 화면을 견주는 동안만 둔다(115-3 에서 옛 방식과 함께 지운다). + */ +const deformListeners = new Set<(on: boolean) => void>(); + +export function corridorDeformOn(): boolean { + return readStateRaw("corridor-deform") !== "0"; +} + +export function setCorridorDeform(on: boolean): void { + writeStateRaw("corridor-deform", on ? "1" : "0"); + deformListeners.forEach((listener) => listener(on)); +} + +/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */ +function roadOnly(build: CorridorBuildResult): CorridorBuildResult { + return { + ...build, + ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"), + planCurves: [], + cutWalls: [], + }; +} /** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ function countTriangles(root: THREE.Object3D | null): number { @@ -104,6 +131,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { let bandSplit: TerrainBandSplit | null = null; let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). + let deformOn = corridorDeformOn(); + const onDeform = (on: boolean): void => { + deformOn = on; + if (corridorBuild) set(corridorBuild); + }; + deformListeners.add(onDeform); /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ function applyCorridorVisibility(): void { @@ -251,7 +284,11 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; const split = ensureBandSplit(buildAtSchedule); if (!split) return; - const clipped = clipTerrain(split, buildAtSchedule, bounds); + const index = deformOn ? deps.heightIndex() : null; + const deformed = index ? deformTerrain(split, buildAtSchedule, bounds, index) : null; + (window as unknown as { __corridorDeform?: unknown }).__corridorDeform = + deformed?.stats ?? null; + const clipped = deformed?.root ?? clipTerrain(split, buildAtSchedule, bounds); disposeClippedTerrain(); clippedTerrain = clipped; scene.add(clipped); @@ -274,9 +311,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { setCorridorBuildSummary(build); const bounds = deps.bounds(); if (build && bounds) { - snapCorridorEdges(build); - attachPatchSkirts(build); - corridorGroup = createCorridorGroup(build, bounds); + // 새 방식은 비탈 리본 · 패치 · 측벽을 그리지 않는다 — 지형 변형이 그 자리를 맡는다. + if (!deformOn) { + snapCorridorEdges(build); + attachPatchSkirts(build); + } + corridorGroup = createCorridorGroup(deformOn ? roadOnly(build) : build, bounds); scene.add(corridorGroup); deps.onGroup(); } @@ -321,6 +361,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { group: () => corridorGroup, clipped: () => clippedTerrain, dispose() { + deformListeners.delete(onDeform); corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); diff --git a/resources/tester/helper_115_corridor_deform.cjs b/resources/tester/helper_115_corridor_deform.cjs new file mode 100644 index 000000000..f9f50c525 --- /dev/null +++ b/resources/tester/helper_115_corridor_deform.cjs @@ -0,0 +1,246 @@ +/* PLAN 115 단위검증 헬퍼 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사. + * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const THREE = require(path.join(ROOT, "config", "node_modules", "three")); + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request === "three") return THREE; + // 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다. + if (request.startsWith("@ui/") || request.startsWith("@config/")) { + return new Proxy({}, { get: () => () => undefined }); + } + const target = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); + return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform")); +const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain")); +const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); +const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); + +const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000); +const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5) +const ROAD_Z = 100; + +/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */ +function station(chainage, mode, groundZ, ratio) { + const w = Math.abs(groundZ - ROAD_Z) * ratio; + const pts = [ + [-30, groundZ], + [-(HALF + w), groundZ], + [-HALF, ROAD_Z], + [-2.5, ROAD_Z], + [2.5, ROAD_Z], + [HALF, ROAD_Z], + [HALF + w, groundZ], + [30, groundZ], + ]; + const samples = []; + for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: chainage, + center_y: 0, + center_z: groundZ, + frame: { left_xy: [0, 1] }, + samples, + design: { + section_mode: mode, + road_edges: { + left: { offset_m: HALF, elevation_m: ROAD_Z }, + right: { offset_m: -HALF, elevation_m: ROAD_Z }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: ROAD_Z }, + right: { offset_m: -2.5, elevation_m: ROAD_Z }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} + +const ROUTE = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, +]; + +function fieldOf(sections, groundAt) { + const build = buildCorridor(sections, ROUTE); + const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt); + return { build, field }; +} + +const results = {}; + +// ① 성토 — 지반 96 · 1:1.5. 비탈 안 정점은 성토선으로 올라가고, 끝(6 m) 밖은 그대로. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const { build, field } = fieldOf(sections, () => 96); + results.fill = { + rows: field.rows.length, + anchorL: field.rows[10].left.map(r4), + left2: r4(field.deformZ(10, HALF + 2, 96)), + right2: r4(field.deformZ(10, -HALF - 2, 96)), + boundary: r4(field.deformZ(10, HALF, 96)), + beyond: r4(field.deformZ(10, HALF + 9, 96)), + // 지반이 성토선보다 높은 정점은 그대로(clamp 아래쪽만 올린다). + above: r4(field.deformZ(10, HALF + 2, 99.5)), + oldRibbons: build.ribbons.length, + }; +} + +// ② 첫 지반 교차점 밖은 원지반 — 비탈 끝(6 m) 너머 골짜기(80)는 연장선(92)으로 메우지 않는다. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const groundAt = (x, y) => (Math.abs(y) - HALF > 10 ? 80 : 96); + const { field } = fieldOf(sections, groundAt); + results.valley = { + inside: r4(field.deformZ(10, HALF + 2, 96)), + farValley: r4(field.deformZ(10, HALF + 15, 80)), + }; +} + +// ③ 절토 — 지반 104 · 1:1. 비탈 안 정점은 절토선으로 내려가고 끝(4 m) 밖은 그대로. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_cut", 104, 1.0)); + const { field } = fieldOf(sections, () => 104); + results.cut = { + left2: r4(field.deformZ(10, HALF + 2, 104)), + beyond: r4(field.deformZ(10, HALF + 6, 104)), + below: r4(field.deformZ(10, HALF + 2, 101)), + }; +} + +// ④ 측점 사이 보간 — 0 측점 1:1.5, 40 측점 1:2. 가운데(20)는 앵커 기준 상대 단면 보간. +{ + const sections = [station(0, "both_fill", 96, 1.5), station(40, "both_fill", 96, 2.0)]; + const { field } = fieldOf(sections, () => 96); + results.taper = { + at0: r4(field.deformZ(0.1, HALF + 2, 96)), + at20: r4(field.deformZ(20, HALF + 2, 96)), + at40: r4(field.deformZ(39.9, HALF + 2, 96)), + }; +} + +// ⑤ 단면 밖 마지막 구배 연장 · 반대 측 빌리기 — 단면 함수 단독. +{ + const profile = Float64Array.from([0, 0, 2, -1, 4, -2]); + results.profile = { + mid: r4(deform.profileZ(profile, 1)), + extend: r4(deform.profileZ(profile, 10)), + noExtend: deform.profileZ(profile, 10, true), + }; +} + +// ⑥ Three 지형 — 사본에만 · XY 고정 · 노면부 구멍 · 색 · 흑백 보존 표식. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5)); + const build = buildCorridor(sections, ROUTE); + build.sections = sections; + const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] }; + const cx = 20; + const cy = 0; + const cz = 100; + const step = 0.5; + const nx = Math.round(60 / step) + 1; + const ny = Math.round(40 / step) + 1; + const positions = new Float32Array(nx * ny * 3); + const colors = new Uint8Array(nx * ny * 4).fill(200); + for (let j = 0; j < ny; j += 1) { + for (let i = 0; i < nx; i += 1) { + const v = j * nx + i; + positions[v * 3] = -10 + i * step - cx; + positions[v * 3 + 1] = 96 - cz; + positions[v * 3 + 2] = -(-20 + j * step - cy); + } + } + const index = []; + for (let j = 0; j < ny - 1; j += 1) { + for (let i = 0; i < nx - 1; i += 1) { + const a = j * nx + i; + index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true)); + geometry.setIndex(index); + const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); + const before = positions.slice(); + const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 }); + const heightIndex = new TerrainHeightIndex(terrain); + const out = deformTerrain(split, build, bounds, heightIndex); + let xyMoved = 0; + let inRoad = 0; + let probe = null; + let keepCount = 0; + let tinted = 0; + out.root.traverse((child) => { + if (!(child instanceof THREE.Mesh) || !child.userData.owned) return; + const p = child.geometry.getAttribute("position"); + const c = child.geometry.getAttribute("color"); + for (let v = 0; v < nx * ny; v += 1) { + if (p.getX(v) !== before[v * 3] || p.getZ(v) !== before[v * 3 + 2]) xyMoved += 1; + const mx = p.getX(v) + cx; + const my = -p.getZ(v) + cy; + if (Math.abs(mx - 10) < 1e-6 && Math.abs(my - (HALF + 2)) < 1e-6) probe = p.getY(v) + cz; + if (child.userData.keepColor[v]) { + keepCount += 1; + if (c.getX(v) !== 200 / 255) tinted += 1; + } + } + const idx = child.geometry.getIndex(); + for (let t = 0; t < idx.count; t += 3) { + let sx = 0; + let sy = 0; + for (let k = 0; k < 3; k += 1) { + sx += p.getX(idx.getX(t + k)) + cx; + sy += -p.getZ(idx.getX(t + k)) + cy; + } + sx /= 3; + sy /= 3; + if (sx > 0.5 && sx < 39.5 && Math.abs(sy) < HALF - 0.05) inRoad += 1; + } + }); + results.three = { + originalUntouched: before.every((value, i) => value === positions[i]), + xyMoved, + inRoad, + probe: r4(probe), + moved: out.stats.moved, + raised: out.stats.raised, + keepCount, + tinted, + }; +} + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_corridor_deform.py b/resources/tester/test_115_corridor_deform.py new file mode 100644 index 000000000..e3fac0cb8 --- /dev/null +++ b/resources/tester/test_115_corridor_deform.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""PLAN 115 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사. + +Node 도우미(helper_115_corridor_deform.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 +결과(JSON)를 단언한다. 정점 규칙 `z' = clamp(지형, 성토선, 절토선)` · 비탈 끝 밖 원지반 · +측점 사이 보간 · 단면 밖 마지막 구배 연장 · 사본에만 · XY 고정 · 노면부 구멍 · 색. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +FILL_2M = round(100 - 2 / 1.5, 4) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_115_corridor_deform.cjs")], + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_fill_clamp_raises_to_fill_line(results): + r = results["fill"] + assert r["rows"] >= 2 + assert r["anchorL"][1:] == [3, 100] + assert r["left2"] == FILL_2M + assert r["right2"] == FILL_2M + assert r["boundary"] == 100 # 노면부 끝 경계 정점 = 앵커 z + assert r["beyond"] == 96 # 비탈 끝(6 m) 밖은 원지반 + assert r["above"] == 99.5 # 지반이 성토선 위면 그대로 + + +def test_fill_stops_at_first_catch(results): + r = results["valley"] + assert r["inside"] == FILL_2M + assert r["farValley"] == 80 # 연장선(92)으로 골짜기를 메우지 않는다 + + +def test_cut_clamp_lowers_to_cut_line(results): + r = results["cut"] + assert r["left2"] == 102 + assert r["beyond"] == 104 + assert r["below"] == 101 # 지반이 절토선 아래면 그대로 + + +def test_station_interpolation(results): + r = results["taper"] + assert abs(r["at0"] - FILL_2M) < 0.02 + assert abs(r["at40"] - 99.0) < 0.02 + assert abs(r["at20"] - round(100 - 2 * 4 / 7, 4)) < 1e-3 # 상대 단면 보간 + + +def test_profile_extends_last_grade(results): + r = results["profile"] + assert r["mid"] == -0.5 + assert r["extend"] == -5 + assert r["noExtend"] is None + + +def test_three_copy_only_xy_fixed_hole_and_tint(results): + r = results["three"] + assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로([예상형상] OFF = 원본) + assert r["xyMoved"] == 0 + assert r["inRoad"] == 0 # 노면부 안 삼각형 없음 + assert r["probe"] == FILL_2M + assert r["moved"] > 0 and r["raised"] == r["moved"] + assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"]