From bf1106aa8e1a3ed4a004253ce58f2a13fbe64f9a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 20:52:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=203D=20=EC=98=9B=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20A(08-25=20=EC=8B=A4=EB=A3=A8=EC=97=A3=20=EA=B0=88?= =?UTF-8?q?=EC=95=84=20=EB=81=BC=EC=9A=B0=EA=B8=B0)=20=EB=B3=84=EA=B0=9C?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=E2=80=94=20=EB=B0=B0=EC=88=98=EA=B4=80?= =?UTF-8?q?=20=EC=84=B8=ED=8A=B8=EB=A7=8C=20=EC=8B=A4=EB=A3=A8=EC=97=A3=20?= =?UTF-8?q?=C2=B7=20=EC=8A=A4=EC=BC=80=EC=B9=98=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=ED=8C=A8=EC=B9=98=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=A7=80=ED=98=95=20=EB=9D=A0=EB=A7=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A6=84=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=95=88=20?= =?UTF-8?q?=ED=95=A8(=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=A6=89?= =?UTF-8?q?=EC=84=9D=20=C2=B7=20=ED=95=B4=EC=8B=9C=EB=B3=84=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8)(115-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 | 15 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 12 +- B05_Profile/B05_Profile_UI_Corridor_Legacy.ts | 60 +++++++ .../B05_Profile_UI_Corridor_Station.ts | 34 ++-- B05_Profile/B05_Profile_UI_Panel_View.ts | 9 +- B05_Profile/B05_Profile_UI_Viewer_Corridor.ts | 54 ++++++- .../tester/helper_115_4_corridor_legacy.cjs | 148 ++++++++++++++++++ .../tester/test_115_4_corridor_legacy.py | 67 ++++++++ 9 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Legacy.ts create mode 100644 resources/tester/helper_115_4_corridor_legacy.cjs create mode 100644 resources/tester/test_115_4_corridor_legacy.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index d67cd3382..c9eff3c47 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -68,6 +68,8 @@ export const STATE_REGISTRY = { "m01-logic-panel": { bucket: "pref", scope: "global" }, /** B05 3D 예상형상 그리기 — "1" 새 방식(지형 변형 · 기본) · "0" 옛 방식(115-1 비교용) */ "corridor-deform": { bucket: "pref", scope: "global" }, + /** B05 3D 옛 방식 A(실루엣 갈아 끼우기 · 115-4 비교용) — "1" 켬 · 그 밖 끔 */ + "corridor-legacy": { 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 94b2b27a3..0f4545232 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -29,6 +29,7 @@ import { serialize, type CorridorEnvelope, } from "./B05_Profile_UI_Corridor_Envelope"; +import { rememberLegacyInputs } from "./B05_Profile_UI_Corridor_Legacy"; /** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */ export interface CorridorStructureHook { @@ -236,12 +237,18 @@ export async function saveCorridorIfDirty(projectId: string, routeId: number): P await writeCachedBytes(projectId, url, bytes); } -/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */ +/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). + * 옛 방식 A(115-4) 입력도 함께 묶는다 — 단추를 켤 때 즉석 빌드용(메모리만). */ function withSections( build: CorridorBuildResult | null, detail: SectionDetailResponse, + routePoints: RoutePoint[], + designSamples?: ProfileSamples, ): CorridorBuildResult | null { - if (build) build.sections = detail.cross_sections; + if (build) { + build.sections = detail.cross_sections; + rememberLegacyInputs(build, { detail, routePoints, designSamples }); + } return build; } @@ -258,7 +265,7 @@ export function refreshCorridor( ): Promise { if (!routeId) return Promise.resolve(); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) - .then((build) => viewer.setCorridor(withSections(build, detail))) + .then((build) => viewer.setCorridor(withSections(build, detail, routePoints, designSamples))) .catch(() => viewer.setCorridor(null)); } @@ -278,7 +285,7 @@ export function loadCorridorIfFresh( if (!routeId) return Promise.resolve(false); return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) .then((build) => { - if (build) viewer.setCorridor(withSections(build, detail)); + if (build) viewer.setCorridor(withSections(build, detail, routePoints, designSamples)); return build !== null; }) .catch(() => false); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 068906506..5b8082bc3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -177,9 +177,15 @@ export function buildCorridor( ground_elevation_m?: number; }>, subdivideStepM: number = SUBDIVIDE_STEP_M, + /** + * 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 배수관 세트 측 비탈을 + * 실루엣으로 갈아 끼우고 스케치 · 자르기 · 패치 없이 리본 · 외곽 · 마구리 · 솔리드만 낸다. + */ + options: { legacy?: boolean } = {}, ): CorridorBuildResult | null { + const legacy = options.legacy === true; const stations = crossSections - .map(classifyStation) + .map((section) => classifyStation(section, { silhouette: legacy })) .filter((s): s is StationPieces => s !== null) .sort((a, b) => a.chainage_m - b.chainage_m); if (stations.length < 2) return null; @@ -272,7 +278,8 @@ export function buildCorridor( }; const structures = buildCorridorStructures(crossSections, frameAt); // 탑뷰 투영 커브가 격자 모서리에 앉도록 구조물 구간 경계를 행으로 끼워 넣는다. - const spanBounds = structureSpanBounds(crossSections); + // 옛 방식 A 는 그날처럼 등간격 행만 쓴다. + const spanBounds = legacy ? [] : structureSpanBounds(crossSections); for (let i = 0; i < stations.length - 1; i += 1) { const s0 = stations[i]; @@ -590,6 +597,7 @@ export function buildCorridor( }); if (points.length >= 2) caps.push({ points }); }); + if (legacy) return { ribbons, outline, caps, structures }; // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Legacy.ts b/B05_Profile/B05_Profile_UI_Corridor_Legacy.ts new file mode 100644 index 000000000..6ff5b3a61 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Legacy.ts @@ -0,0 +1,60 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Legacy.ts + * 3D 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 비교용 빌드. + * + * 저장하지 않는다(저장본 · BUILD_VERSION · 해시 · 서버 사전생성 무관). [옛 방식 A] 단추를 + * 처음 켤 때 브라우저가 즉석으로 만들고 해시별로 메모한다. 입력은 본체 `refreshCorridor` + * 가 코리도를 뷰어에 넘길 때 그 빌드에 묶어 둔다. + * ========================================================================== */ + +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import { corridorHash } from "./B05_Profile_UI_Corridor_Envelope"; + +interface LegacyInputs { + detail: SectionDetailResponse; + routePoints: RoutePoint[]; + designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>; + hash?: string; +} + +const inputsOf = new WeakMap(); +let memo: { hash: string; build: CorridorBuildResult | null } | null = null; + +/** 본체 코리도 빌드에 옛 방식 A 입력을 묶어 둔다(메모리만). */ +export function rememberLegacyInputs(build: CorridorBuildResult, inputs: LegacyInputs): void { + inputsOf.set(build, inputs); +} + +function hashOf(inputs: LegacyInputs): string { + inputs.hash ??= corridorHash(inputs.detail, inputs.routePoints); + return inputs.hash; +} + +/** 이 빌드의 옛 방식 A 가 이미 만들어져 있는가(입력이 없으면 만들 것도 없다). */ +export function legacyReady(build: CorridorBuildResult): boolean { + const inputs = inputsOf.get(build); + return !inputs || memo?.hash === hashOf(inputs); +} + +/** 옛 방식 A 빌드 — 해시가 같으면 메모를 쓴다. 입력이 없거나 빌드 실패면 null. */ +export function legacyCorridorOf(build: CorridorBuildResult): CorridorBuildResult | null { + const inputs = inputsOf.get(build); + if (!inputs) return null; + const hash = hashOf(inputs); + if (memo?.hash !== hash) { + const { detail, routePoints, designSamples } = inputs; + memo = { + hash, + build: buildCorridor( + detail.cross_sections, + routePoints, + designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, + undefined, + { legacy: true }, + ), + }; + } + return memo.build; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 88f575d3a..c3571196a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -88,17 +88,17 @@ export interface StationPieces { } /** - * 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 로직의 on/off - * (2026-08-25 사용자 지시: 기존 성토면을 강제 변형시키던 로직 비활성화). + * 측점 분류 선택(115-4). * - * 껐을 때의 동작: 성토·절토 비탈은 측점 설계선 그대로 지반까지 간다. 구조물과 겹치는 - * 자리는 나중에 **탑뷰 투영면적으로 도려낸다**(`_Corridor_Region.ts`의 StructureFootprint - * → `_Corridor_Clip.ts`). 서피스를 비틀지 않고 잘라내는 쪽이 형상이 깨끗하다. - * - * 켜면 종전 동작(노폭 연장 + 구조물 실루엣 + 구간 내 지반 트림 금지)으로 돌아간다 — - * 되돌릴 일이 있어 코드는 남겨 둔다. + * `silhouette` — 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 옛 방식 A + * (08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」). 기본 끔(2026-08-25 사용자 지시: 기존 성토면을 + * 강제 변형시키던 로직 비활성화) — 끄면 비탈은 측점 설계선 그대로 지반까지 가고 구조물과 + * 겹치는 자리는 탑뷰 투영으로 도려낸다. 켜면 그날 그대로 **배수관 세트만** 갈아 끼우고 + * (BOX · 세월교는 원본 비탈) 노견 확장 상시 적용(08-25 ①, 그날 뒤)은 하지 않는다. */ -const STRUCTURE_SILHOUETTE_OVERRIDE: boolean = false; +export interface ClassifyOptions { + silhouette?: boolean; +} export function pieceKey(kind: CorridorKind, side: CorridorSide): string { return `${kind}:${side}`; @@ -234,7 +234,11 @@ function relativeToRoadEdge( } /** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */ -export function classifyStation(section: CrossSection): StationPieces | null { +export function classifyStation( + section: CrossSection, + /** `.map(classifyStation)` 이 넘기는 인덱스(number)는 끔으로 읽는다. */ + options?: ClassifyOptions | number, +): StationPieces | null { const design = section.design; const line = design?.design_line; if (!design || !line || line.length < 2) return null; @@ -394,11 +398,13 @@ export function classifyStation(section: CrossSection): StationPieces | null { }; // ── 구조물 측점(배수관 세트·BOX암거·세월교) — 그 측 비탈을 B06 횡단 실루엣으로 - // 갈아 끼우고 종방향 점유 구간을 등록한다. 지금은 꺼져 있다 - // (STRUCTURE_SILHOUETTE_OVERRIDE) — 성토면은 그대로 두고 투영커브로 도려낸다. + // 갈아 끼우고 종방향 점유 구간을 등록한다. 기본은 꺼져 있다(options.silhouette · + // 옛 방식 A 단추만 켬) — 성토면은 그대로 두고 투영커브로 도려낸다. const silhouettes = structureSilhouettes(section); - for (const silhouette of STRUCTURE_SILHOUETTE_OVERRIDE ? silhouettes : []) { + const legacy = typeof options === "object" && options.silhouette === true; + const drainageOnly = section.box || section.ford ? [] : silhouettes; + for (const silhouette of legacy ? drainageOnly : []) { const side = silhouette.side; const kind: CorridorKind = silhouette.kind === "auto" ? (side === "left" ? leftRole : rightRole) : silhouette.kind; @@ -409,7 +415,7 @@ export function classifyStation(section: CrossSection): StationPieces | null { // 노견 조각을 넓힌다. 원 노견 끝을 꼭짓점으로 남긴다(무릎 — 물매 유지). 넓힌 // 노견은 shoulderSpans 제어점으로 구조물 구간 내내 유지된다. let shoulderSpans: StationPieces["shoulderSpans"]; - for (const silhouette of silhouettes) { + for (const silhouette of legacy ? [] : silhouettes) { const widen = silhouette.widenTo; const side = silhouette.side; if (!widen) continue; diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index c6a16c915..bba85e054 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -5,7 +5,12 @@ * ========================================================================== */ import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; -import { corridorDeformOn, setCorridorDeform } from "./B05_Profile_UI_Viewer_Corridor"; +import { + corridorDeformOn, + corridorLegacyOn, + setCorridorDeform, + setCorridorLegacy, +} from "./B05_Profile_UI_Viewer_Corridor"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ export interface ViewControlCallbacks { @@ -65,6 +70,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): { toggleButton("예상형상", true, callbacks.onCorridorVisible), // 예상형상 그리기(115-1) — 켬 = 새 방식(지형 메시 변형) · 끔 = 옛 방식(스케치 · 패치). toggleButton("새 방식", corridorDeformOn(), setCorridorDeform), + // 옛 방식 A(115-4) — 08-25 실루엣 갈아 끼우기 · 켜면 새 방식 단추와 상관없이 A 형상. + toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy), 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_Corridor.ts b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts index 158b4f194..355481255 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts @@ -21,6 +21,8 @@ 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"; +import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy"; +import { showToast } from "@ui/ui_template_elements"; /** * 3D 예상형상 그리기 방식(115-1) — 새 방식(지형 메시 변형 · 기본) / 옛 방식(스케치 · 패치). @@ -37,6 +39,21 @@ export function setCorridorDeform(on: boolean): void { deformListeners.forEach((listener) => listener(on)); } +/** + * 옛 방식 A(115-4 · 08-25 실루엣 갈아 끼우기) — 켜면 「새 방식」 단추와 상관없이 A 형상. + * 끄면 지금처럼(새 방식 켬/끔). 비교용 — 저장하지 않는다. + */ +const legacyListeners = new Set<(on: boolean) => void>(); + +export function corridorLegacyOn(): boolean { + return readStateRaw("corridor-legacy") === "1"; +} + +export function setCorridorLegacy(on: boolean): void { + writeStateRaw("corridor-legacy", on ? "1" : "0"); + legacyListeners.forEach((listener) => listener(on)); +} + /** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */ function roadOnly(build: CorridorBuildResult): CorridorBuildResult { return { @@ -137,6 +154,14 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { if (corridorBuild) set(corridorBuild); }; deformListeners.add(onDeform); + let legacyOn = corridorLegacyOn(); + /** 지금 그리고 있는 옛 방식 A 빌드 — 본체 빌드와 따로 둔 객체(심 보정이 제자리 수정). */ + let legacyBuild: CorridorBuildResult | null = null; + const onLegacy = (on: boolean): void => { + legacyOn = on; + if (corridorBuild) set(corridorBuild); + }; + legacyListeners.add(onLegacy); /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ function applyCorridorVisibility(): void { @@ -282,13 +307,14 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { const bounds = deps.bounds(); // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; - const split = ensureBandSplit(buildAtSchedule); + const split = ensureBandSplit(legacyBuild ?? buildAtSchedule); if (!split) return; - const index = deformOn ? deps.heightIndex() : null; + // 옛 방식 A 는 지형을 비탈 끝 띠만 자른다(스케치 · 자른 자리 없음 → 원지반 되살림 없음). + const index = deformOn && !legacyBuild ? 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); + const clipped = deformed?.root ?? clipTerrain(split, legacyBuild ?? buildAtSchedule, bounds); disposeClippedTerrain(); clippedTerrain = clipped; scene.add(clipped); @@ -306,11 +332,28 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { } function set(build: CorridorBuildResult | null): void { + // 옛 방식 A 는 처음 켤 때 즉석으로 만든다(몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다. + if (build && legacyOn && !legacyReady(build)) { + corridorBuild = build; + showToast("옛 방식 A(실루엣) 형상을 만드는 중입니다…", "info"); + window.setTimeout(() => { + if (corridorBuild !== build || !legacyOn) return; + legacyCorridorOf(build); + set(build); + }, 50); + return; + } disposeCorridorGroup(); corridorBuild = build; - setCorridorBuildSummary(build); + legacyBuild = build && legacyOn ? legacyCorridorOf(build) : null; + setCorridorBuildSummary(legacyBuild ?? build); const bounds = deps.bounds(); - if (build && bounds) { + if (legacyBuild && bounds) { + snapCorridorEdges(legacyBuild); + corridorGroup = createCorridorGroup(legacyBuild, bounds); + scene.add(corridorGroup); + deps.onGroup(); + } else if (build && bounds) { // 새 방식은 비탈 리본 · 패치 · 측벽을 그리지 않는다 — 지형 변형이 그 자리를 맡는다. if (!deformOn) { snapCorridorEdges(build); @@ -362,6 +405,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { clipped: () => clippedTerrain, dispose() { deformListeners.delete(onDeform); + legacyListeners.delete(onLegacy); corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); diff --git a/resources/tester/helper_115_4_corridor_legacy.cjs b/resources/tester/helper_115_4_corridor_legacy.cjs new file mode 100644 index 000000000..9d95589c5 --- /dev/null +++ b/resources/tester/helper_115_4_corridor_legacy.cjs @@ -0,0 +1,148 @@ +/* PLAN 115-4 단위검증 헬퍼 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 빌드. + * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 입력 = 픽스처(배수관 세트 · BOX · 세월교 측점). + * 결과는 표준출력에 JSON 한 벌. 짝 = `test_115_4_corridor_legacy.py`. + */ +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); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + 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 base = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + const found = [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + return loadTs(found ?? `${base}.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 { classifyStation } = loadTs(B05("B05_Profile_UI_Corridor_Station")); +const fixture = (name) => + JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", `${name}.json`), "utf8")) + .cross_sections; +const r3 = (v) => Math.round(v * 1000) / 1000; + +/** 양 끝을 잇는 직선에서 가장 먼 점의 거리(m) — 직선 비탈이면 0. */ +function bend(points) { + const a = points[0]; + const b = points[points.length - 1]; + const dx = b.offset_m - a.offset_m; + const dz = b.elevation_m - a.elevation_m; + const len = Math.hypot(dx, dz) || 1; + return Math.max( + ...points.map( + (p) => Math.abs((p.offset_m - a.offset_m) * dz - (p.elevation_m - a.elevation_m) * dx) / len, + ), + ); +} + +const results = {}; + +// ① 배수관 세트 측점(85.052 · 우절토 · 유출 좌측 성토) — 픽스처에 평면 자리가 없어 직선 노선에 얹는다. +{ + const sections = fixture("b07_cross_overlay_sections").map((s) => ({ + ...s, + station_id: `S${s.chainage_m}`, + center_x: s.chainage_m, + center_y: 0, + frame: { left_xy: [0, 1] }, + })); + const set = sections.find((s) => s.culvert); + const plain = classifyStation(set); + const legacy = classifyStation(set, { silhouette: true }); + const side = set.uphill_side === "right" ? "left" : "right"; // 유출측 + const key = `fill:${side}`; + const p0 = plain.pieces.get(key); + const p1 = legacy.pieces.get(key); + results.set = { + chainage: set.chainage_m, + key, + plainBend: r3(bend(p0)), + legacyBend: r3(bend(p1)), + plainRatio: r3( + Math.abs(p0[p0.length - 1].offset_m - p0[0].offset_m) / + Math.abs(p0[p0.length - 1].elevation_m - p0[0].elevation_m), + ), + wallSpans: Object.keys(legacy.wallSpans ?? {}).sort(), + plainWallSpans: plain.wallSpans ?? null, + indexArg: + JSON.stringify([...classifyStation(set, 3).pieces]) === JSON.stringify([...plain.pieces]), + }; + const route = sections.map((s) => ({ x: s.center_x, y: 0 })); + const base = buildCorridor(sections, route); + const old = buildCorridor(sections, route, undefined, undefined, { legacy: true }); + const ribbonAt = (build) => + build.ribbons.find((r) => r.kind === "fill" && r.side === side && !r.patch); + const rowAt = (ribbon, chainage) => { + let best = 0; + ribbon.chainages.forEach((c, i) => { + if (Math.abs(c - chainage) < Math.abs(ribbon.chainages[best] - chainage)) best = i; + }); + const pts = []; + for (let c = 0; c < ribbon.colCount; c += 1) { + const i = (best * ribbon.colCount + c) * 3; + pts.push({ offset_m: ribbon.positions[i + 1], elevation_m: ribbon.positions[i + 2] }); + } + return pts; + }; + results.build = { + basePlanCurves: Array.isArray(base.planCurves), + baseCutWalls: Array.isArray(base.cutWalls), + keys: Object.keys(old).sort(), + patches: old.ribbons.filter((r) => r.patch).length, + structures: old.structures.length, + rowBend: r3(bend(rowAt(ribbonAt(old), set.chainage_m))), + baseRowBend: r3(bend(rowAt(ribbonAt(base), set.chainage_m))), + rows: ribbonAt(old).chainages.length, + }; +} + +// ② BOX(352.14) · 세월교(173.087) 측점 — 옛 방식 A 에서도 비탈은 원본(실루엣 없음). +{ + const sections = fixture("b06_73_4_body_guard_sections"); + results.others = sections + .filter((s) => s.box || s.ford) + .map((s) => { + const plain = classifyStation(s); + const legacy = classifyStation(s, { silhouette: true }); + const slopes = (st) => + JSON.stringify( + [...st.pieces].filter(([k]) => k.startsWith("cut:") || k.startsWith("fill:")), + ); + return { + chainage: s.chainage_m, + kind: s.box ? "box" : "ford", + sameSlopes: slopes(plain) === slopes(legacy), + wallSpans: legacy.wallSpans ?? null, + shoulderSpans: legacy.shoulderSpans ?? null, + }; + }); +} + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_4_corridor_legacy.py b/resources/tester/test_115_4_corridor_legacy.py new file mode 100644 index 000000000..339380e4e --- /dev/null +++ b/resources/tester/test_115_4_corridor_legacy.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""PLAN 115-4 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 별개 단추. + +Node 도우미(helper_115_4_corridor_legacy.cjs)가 TS 를 트랜스파일해 픽스처 측점으로 돌리고, +여기서는 결과(JSON)를 단언한다. 배수관 세트 측 성토 리본이 직선이 아니라 실루엣을 따름 · +스케치 · 자르기 · 패치 없음 · BOX · 세월교는 원본 비탈 · 기본 빌드 그대로 · 설정 키 등록. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_115_4_corridor_legacy.cjs")], + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_drainage_set_fill_follows_silhouette(results): + r = results["set"] + assert r["plainBend"] < 0.05 # 기본 = 직선 성토 비탈(1:1.2 꼴) + assert 1.1 < r["plainRatio"] < 1.4 + assert r["legacyBend"] > 0.5 # 옛 방식 A = 벽 상단 · 전면 · 성토부선 실루엣 + assert r["wallSpans"] == ["left", "right"] + assert r["plainWallSpans"] is None + assert r["indexArg"] is True # `.map(classifyStation)` 인덱스는 끔 + + +def test_legacy_build_has_no_sketch_cut_patch(results): + r = results["build"] + assert r["keys"] == ["caps", "outline", "ribbons", "structures"] # planCurves · cutWalls 없음 + assert r["patches"] == 0 + assert r["structures"] > 0 # 솔리드는 지금 것 그대로 얹음 + assert r["rowBend"] > 0.5 # 측점 행 성토 리본이 실루엣 따름 + assert r["baseRowBend"] < 0.05 + assert r["basePlanCurves"] is True and r["baseCutWalls"] is True # 기본 빌드 그대로 + + +def test_box_and_ford_keep_original_slopes(results): + kinds = {item["kind"]: item for item in results["others"]} + assert set(kinds) == {"box", "ford"} + for item in kinds.values(): + assert item["sameSlopes"] is True + assert item["wallSpans"] is None + assert item["shoulderSpans"] is None # 노견 확장 상시 적용(08-25 ①)도 그날 전 + + +def test_legacy_state_key_registered(): + registry = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert '"corridor-legacy": { bucket: "pref", scope: "global" }' in registry + assert '"corridor-deform": { bucket: "pref", scope: "global" }' in registry + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8") + assert 'toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy)' in panel + assert 'toggleButton("새 방식", corridorDeformOn(), setCorridorDeform)' in panel