feat(B05): 코리도를 전처리 체인이 미리 만들어 영구저장 + 초기값 편입
사용자 확정(2026-09-04) — 「다른 로직과 동일하게: 전처리 계산 마지막에 연산 후 영구저장, 진입 시 로딩, 조작은 캐시, 저장·확정 때 저장」. 예전에는 사용자가 B05에 처음 들어간 그 순간 브라우저가 17MB 규모를 만들어 첫 진입이 느렸음. 계산은 **재구현하지 않음** — 브라우저가 쓰는 TS 빌더(8,209줄)를 Node 로 그대로 돌림. - `B05_Profile_UI_Corridor_Envelope.ts` 신설 — 저장 형식·버전 해시·직렬화를 `_UI_Corridor.ts` 에서 그대로 떼어 브라우저·서버 공용으로 둠(내용 불변). - `B05_Profile_Corridor_Node.ts` 신설 — 입력 JSON 을 받아 저장본을 내놓는 진입점. `npm run build:corridor` 로 번들(335kB), `npm run build` 에 물림. - `B05_Profile_Corridor_Prebuild.py` 신설 — 상세·노선점을 모아 Node 실행 후 저장. 번들이 TS 원본보다 낡으면 스스로 다시 만듦(두 그림이 갈라지는 것을 막는 장치). - 체인 마지막(스냅샷 직전)에 호출. 실패는 비치명적 — 저장본이 없으면 브라우저 폴백. - 초기값 — 스냅샷이 코리도를 `initial_corridor.json` 으로 함께 뜨고, [초기화] 복원 때 **새 route id** 이름으로 되돌림(복원이 새 번호를 만들기 때문). - `window.__corridorSource` 디버그 훅 — 저장본을 썼는지 다시 만들었는지 화면 밖 확인용. 자체검증: 전체 시험 381 통과·17 건너뜀(옛 테스트 2건은 이름·경로 변경에 맞춰 갱신). 실경로 — 용화 프로젝트(route 139)로 사전 생성 성공, 브라우저가 그 저장본을 `source: "stored"`, 해시 `377c2a84` 로 **그대로 채택**(재빌드 0회, 전송 278ms). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -54,4 +54,7 @@ resources/data_global_contours/national_contours.gpkg
|
||||
.tmp_*
|
||||
*.log.err
|
||||
tmp/
|
||||
**/.obsidian/
|
||||
**/.obsidian/
|
||||
# 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04).
|
||||
# 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py).
|
||||
config/corridor_node/
|
||||
|
||||
@@ -270,6 +270,16 @@ async def run_auto_design_chain(
|
||||
route_id,
|
||||
)
|
||||
|
||||
# 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다
|
||||
# (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가
|
||||
# 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다.
|
||||
try:
|
||||
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
|
||||
|
||||
await prebuild_corridor(project_id, route_id, project_root)
|
||||
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
|
||||
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
|
||||
|
||||
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
|
||||
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
||||
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Corridor_Node.ts
|
||||
* 코리도(3D 예상형상)를 **서버에서 한 번** 만들어 저장본 형식으로 내놓는 진입점.
|
||||
*
|
||||
* 왜 있나(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤
|
||||
* 전처리 체인 마지막에 계산해 영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 →
|
||||
* [저장]·[확정] 때 저장. 예전에는 사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어
|
||||
* 첫 진입이 느렸다.
|
||||
*
|
||||
* **계산을 다시 짜지 않는다.** 브라우저가 쓰는 빌더(`_UI_Corridor_Build`)와 저장 형식
|
||||
* (`_UI_Corridor_Envelope`)을 그대로 부른다 — 두 벌이 되면 화면과 서버가 다른 그림을
|
||||
* 만든다. 빌더 계통에는 Three.js·화면 API가 없어 Node에서 그대로 돈다.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { detail: 종횡단 상세(API와 같은 꼴), route_points: 노선 폴리라인 }
|
||||
* 출력 브라우저가 GET으로 받는 저장본 그대로(버전·해시 포함)
|
||||
* 끝 코드: 0 성공 / 2 인자 오류 / 3 측점 부족(만들 것 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import { buildCorridor } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { corridorHash, serialize } from "./B05_Profile_UI_Corridor_Envelope";
|
||||
|
||||
interface CorridorNodeInput {
|
||||
detail: SectionDetailResponse;
|
||||
route_points: RoutePoint[];
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as CorridorNodeInput;
|
||||
const detail = input.detail;
|
||||
const routePoints = input.route_points ?? [];
|
||||
// 편집 전이므로 계획선 샘플은 정본 그대로다 — 브라우저의 폴백 경로와 같은 값이다.
|
||||
const samples = detail.longitudinal?.design_profiles?.[0]?.samples;
|
||||
const build = buildCorridor(detail.cross_sections, routePoints, samples);
|
||||
if (!build) {
|
||||
console.error("코리도 없음 — 분류 가능한 측점이 2개 미만.");
|
||||
process.exit(3);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify(serialize(build, corridorHash(detail, routePoints))));
|
||||
@@ -0,0 +1,151 @@
|
||||
"""코리도(3D 예상형상) 사전 생성 — 전처리 체인 마지막에 서버가 한 번 만들어 영구저장한다.
|
||||
|
||||
왜(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤 계산해
|
||||
영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 → [저장]·[확정] 때 저장. 예전에는
|
||||
사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어(실측 17MB 규모) 첫 진입이 느렸다.
|
||||
|
||||
**계산을 다시 짜지 않는다.** 브라우저가 쓰는 TS 빌더를 Node로 그대로 돌린다
|
||||
(`B05_Profile_Corridor_Node.ts` → 번들). 파이썬으로 포팅하면 같은 기하가 두 벌이 되어
|
||||
화면과 3D가 갈라진다.
|
||||
|
||||
실패는 비치명적이다 — 저장본이 없으면 브라우저가 예전처럼 직접 만든다(폴백 유지).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B05_Profile.B05_Profile_Repository import get_route_points
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js"
|
||||
# 번들이 낡았는지 재는 대상 — 빌더 계통이 걸쳐 있는 폴더.
|
||||
_SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util")
|
||||
# 번들 만들기·실행 상한(초). 실측 번들 0.1초, 빌드 65측점 3초 수준이라 넉넉하다.
|
||||
_BUILD_TIMEOUT_S = 300
|
||||
_RUN_TIMEOUT_S = 600
|
||||
|
||||
|
||||
def _node_env() -> dict[str, str]:
|
||||
"""config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약)."""
|
||||
env = os.environ.copy()
|
||||
node_modules = ROOT / "config" / "node_modules"
|
||||
env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}"
|
||||
env["NODE_PATH"] = str(node_modules)
|
||||
return env
|
||||
|
||||
|
||||
def _bundle_stale() -> bool:
|
||||
"""번들이 없거나 TS 원본보다 오래됐으면 참.
|
||||
|
||||
번들이 낡으면 서버와 화면이 **다른 기하**를 만든다 — 이 판정이 그것을 막는 유일한
|
||||
장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다.
|
||||
"""
|
||||
if not BUNDLE.is_file():
|
||||
return True
|
||||
built_at = BUNDLE.stat().st_mtime
|
||||
for directory in _SOURCE_DIRS:
|
||||
for path in (ROOT / directory).rglob("*.ts"):
|
||||
if path.stat().st_mtime > built_at:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_bundle() -> bool:
|
||||
result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음
|
||||
"npm run build:corridor",
|
||||
shell=True,
|
||||
cwd=str(ROOT),
|
||||
env=_node_env(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_BUILD_TIMEOUT_S,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("코리도 번들 빌드 실패:\n%s", result.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _run_node(input_path: Path, output_path: Path) -> int:
|
||||
result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐
|
||||
["node", str(BUNDLE), str(input_path), str(output_path)],
|
||||
cwd=str(ROOT),
|
||||
env=_node_env(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_RUN_TIMEOUT_S,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("코리도 사전 생성 실패(끝 코드 %s): %s", result.returncode, result.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None:
|
||||
"""브라우저가 받는 것과 **같은** 종횡단 상세를 만든다 — 같은 라우터 함수를 부른다."""
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
response = await get_section_detail(UUID(str(project_id)), route_id)
|
||||
payload = getattr(response, "model_dump", None)
|
||||
if payload is None: # JSONResponse = 실패
|
||||
logger.warning("코리도 사전 생성: 종횡단 상세를 못 받음 (route_id=%s)", route_id)
|
||||
return None
|
||||
return payload(mode="json")
|
||||
|
||||
|
||||
async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: Path) -> bool:
|
||||
"""코리도를 만들어 영구저장소에 남긴다. 성공하면 참."""
|
||||
detail = await _section_detail(project_id, route_id)
|
||||
if detail is None:
|
||||
return False
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
points = await get_route_points(connection, route_id)
|
||||
if len(points) < 2:
|
||||
logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id)
|
||||
return False
|
||||
|
||||
if _bundle_stale() and not await asyncio.to_thread(_build_bundle):
|
||||
return False
|
||||
|
||||
target = corridor_path(Path(project_root), route_id)
|
||||
with tempfile.TemporaryDirectory(prefix="corridor_") as workdir:
|
||||
source = Path(workdir) / "input.json"
|
||||
built = Path(workdir) / "corridor.json"
|
||||
await asyncio.to_thread(
|
||||
source.write_text,
|
||||
json.dumps({"detail": detail, "route_points": points}, default=float),
|
||||
"utf-8",
|
||||
)
|
||||
if await asyncio.to_thread(_run_node, source, built) != 0:
|
||||
return False
|
||||
await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(_replace, built, target)
|
||||
logger.info(
|
||||
"코리도 사전 생성 완료: route_id=%s 크기=%.1fMB",
|
||||
route_id,
|
||||
target.stat().st_size / (1024 * 1024),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _replace(source: Path, target: Path) -> None:
|
||||
"""임시 폴더가 다른 드라이브일 수 있어 os.replace 대신 복사 후 지운다."""
|
||||
target.write_bytes(source.read_bytes())
|
||||
source.unlink(missing_ok=True)
|
||||
@@ -28,7 +28,8 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"])
|
||||
MAX_CORRIDOR_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _corridor_path(project_root: Path, route_id: int) -> Path:
|
||||
def corridor_path(project_root: Path, route_id: int) -> Path:
|
||||
"""코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다."""
|
||||
return project_root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json"
|
||||
|
||||
|
||||
@@ -72,7 +73,7 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response:
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = _corridor_path(project_root, route_id)
|
||||
path = corridor_path(project_root, route_id)
|
||||
if not path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
@@ -109,7 +110,7 @@ async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(...
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = _corridor_path(project_root, route_id)
|
||||
path = corridor_path(project_root, route_id)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(atomic_write_json, path, payload)
|
||||
return JSONResponse(content={"status": "ok"})
|
||||
|
||||
@@ -198,6 +198,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
},
|
||||
)
|
||||
|
||||
# 복원이 만든 새 route id — 코리도 초기값을 그 번호로 되돌리는 데 쓴다.
|
||||
restored_route_id: int | None = None
|
||||
async with pool.acquire() as connection:
|
||||
# 확정 지표면 모델을 초기 체인과 **같은 기준**으로 다시 찾는다 — 체인은 stage 1
|
||||
# 확정 선택값을 쓴다(2026-08-30). config 기본값을 쓰면 그 선택과 어긋난 모델을
|
||||
@@ -219,7 +221,9 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
deleted = cursor.rowcount
|
||||
# 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다.
|
||||
if restored and project_root:
|
||||
await restore_initial_snapshot(connection, project_root, str(project_id))
|
||||
restored_route_id = await restore_initial_snapshot(
|
||||
connection, project_root, str(project_id)
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -227,8 +231,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
|
||||
if restored and project_root:
|
||||
# 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아
|
||||
# 초기값이 오염된다(2026-08-29).
|
||||
await asyncio.to_thread(restore_snapshot_files, project_root)
|
||||
# 초기값이 오염된다(2026-08-29). 코리도는 새 route id 이름으로 되돌아간다.
|
||||
await asyncio.to_thread(restore_snapshot_files, project_root, restored_route_id)
|
||||
else:
|
||||
# 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야
|
||||
# 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
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 {
|
||||
buildCorridor,
|
||||
type CorridorBuildResult,
|
||||
type CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures";
|
||||
corridorHash,
|
||||
ENVELOPE_VERSION,
|
||||
deserialize,
|
||||
serialize,
|
||||
type CorridorEnvelope,
|
||||
} from "./B05_Profile_UI_Corridor_Envelope";
|
||||
|
||||
/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */
|
||||
export interface CorridorStructureHook {
|
||||
@@ -29,58 +31,6 @@ export interface CorridorStructureHook {
|
||||
applies: (chainageStart: number, chainageEnd: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다.
|
||||
*
|
||||
* 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다.
|
||||
*
|
||||
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
|
||||
* 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27.
|
||||
* 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자).
|
||||
* 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가
|
||||
* 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02).
|
||||
*/
|
||||
const ENVELOPE_VERSION = 6;
|
||||
|
||||
interface CorridorEnvelope {
|
||||
version: number;
|
||||
hash: string;
|
||||
ribbons: Array<{
|
||||
kind: CorridorRibbon["kind"];
|
||||
side: CorridorRibbon["side"];
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
positionsBase64: string;
|
||||
/**
|
||||
* 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도
|
||||
* 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연
|
||||
* 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다.
|
||||
*/
|
||||
patch?: boolean;
|
||||
patchClip?: boolean;
|
||||
/** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */
|
||||
cellMaskBase64?: string;
|
||||
/** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */
|
||||
trimTrisBase64?: string;
|
||||
trimUvsBase64?: string;
|
||||
}>;
|
||||
outline: CorridorBuildResult["outline"];
|
||||
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
|
||||
caps?: CorridorBuildResult["caps"];
|
||||
/** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */
|
||||
structures?: CorridorBuildResult["structures"];
|
||||
/**
|
||||
* 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면
|
||||
* 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다.
|
||||
*/
|
||||
planCurves?: CorridorBuildResult["planCurves"];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */
|
||||
cutWalls?: Array<{
|
||||
side: "left" | "right";
|
||||
positionsBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
hash: string;
|
||||
build: CorridorBuildResult;
|
||||
@@ -93,267 +43,6 @@ function keyOf(projectId: string, routeId: number): string {
|
||||
return `${projectId}:${routeId}`;
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */
|
||||
function fnv1a(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.**
|
||||
*
|
||||
* 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아
|
||||
* 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다
|
||||
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
|
||||
* 섞어 두면 배포와 동시에 저장본이 만료된다.
|
||||
*/
|
||||
// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정·
|
||||
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
|
||||
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
|
||||
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
|
||||
// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게
|
||||
// 아니라 리본이 끝난 자리다(2026-08-27 사용자).
|
||||
// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) +
|
||||
// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정),
|
||||
// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토)
|
||||
// (2026-08-27 사용자).
|
||||
// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다
|
||||
// (2026-08-27 사용자).
|
||||
// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴
|
||||
// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자).
|
||||
// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인)
|
||||
// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반
|
||||
// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계
|
||||
// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형
|
||||
// 성토면이 있어야 한다 — 삭제가 아니다).
|
||||
// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서
|
||||
// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이
|
||||
// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복").
|
||||
// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다
|
||||
// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다),
|
||||
// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`,
|
||||
// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로.
|
||||
// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토
|
||||
// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자).
|
||||
// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라
|
||||
// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서
|
||||
// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자:
|
||||
// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림").
|
||||
// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`,
|
||||
// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로
|
||||
// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다
|
||||
// (2026-08-27 사용자: "좌측 스윕 더 가야 함").
|
||||
// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과
|
||||
// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.)
|
||||
// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단
|
||||
// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로").
|
||||
// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에
|
||||
// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.)
|
||||
// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`).
|
||||
// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다
|
||||
// (2026-08-27 사용자). 안쪽은 안 자른다.
|
||||
// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니
|
||||
// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로
|
||||
// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다
|
||||
// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지").
|
||||
// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거,
|
||||
// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고
|
||||
// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다
|
||||
// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다).
|
||||
// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은
|
||||
// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 —
|
||||
// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측).
|
||||
// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자).
|
||||
// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열
|
||||
// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는
|
||||
// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다**
|
||||
// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선
|
||||
// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다.
|
||||
// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥
|
||||
// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이
|
||||
// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해
|
||||
// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히
|
||||
// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이
|
||||
// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다").
|
||||
// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로
|
||||
// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지
|
||||
// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m).
|
||||
// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취
|
||||
// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다
|
||||
// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출").
|
||||
// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용).
|
||||
// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로
|
||||
// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선
|
||||
// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고
|
||||
// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음
|
||||
// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이
|
||||
// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가
|
||||
// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은
|
||||
// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다.
|
||||
// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로
|
||||
// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을
|
||||
// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다
|
||||
// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로
|
||||
// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다
|
||||
// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까").
|
||||
// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어
|
||||
// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**.
|
||||
// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다
|
||||
// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다.
|
||||
// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을
|
||||
// 가르고 **닫힌 고리만** 담는다.
|
||||
// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견
|
||||
// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자).
|
||||
// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤
|
||||
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
|
||||
// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2,
|
||||
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
|
||||
// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와
|
||||
// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자).
|
||||
// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만
|
||||
// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치
|
||||
// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취
|
||||
// 영역을 내므로 같은 반대 규칙이 성립한다.
|
||||
// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가
|
||||
// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆
|
||||
// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 —
|
||||
// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다.
|
||||
// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를
|
||||
// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길
|
||||
// 부재를 못 정하므로 만료한다.
|
||||
// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로
|
||||
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
||||
const BUILD_VERSION = 92;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
const parts: Array<string | number> = [`v${BUILD_VERSION}`, routePoints.length];
|
||||
routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2)));
|
||||
detail.cross_sections.forEach((section) => {
|
||||
const design = section.design;
|
||||
parts.push(
|
||||
section.chainage_m.toFixed(3),
|
||||
section.center_x.toFixed(2),
|
||||
section.center_y.toFixed(2),
|
||||
);
|
||||
if (!design) {
|
||||
parts.push("nodesign");
|
||||
return;
|
||||
}
|
||||
parts.push(
|
||||
design.design_elevation_m.toFixed(3),
|
||||
design.section_mode,
|
||||
design.ditch_side ?? "",
|
||||
design.ditch_type ?? "",
|
||||
String(design.ditch_enabled ?? ""),
|
||||
design.design_line.length,
|
||||
);
|
||||
design.design_line.forEach((point) =>
|
||||
parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)),
|
||||
);
|
||||
// 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다.
|
||||
structureHashParts(section).forEach((part) => parts.push(part));
|
||||
});
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
|
||||
function base64FromFloats(values: Float64Array): string {
|
||||
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(encoded: string): Uint8Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function floatsFromBase64(encoded: string): Float64Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Float64Array(bytes.buffer);
|
||||
}
|
||||
|
||||
function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
return {
|
||||
version: ENVELOPE_VERSION,
|
||||
hash,
|
||||
ribbons: build.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positionsBase64: base64FromFloats(ribbon.positions),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}),
|
||||
...(ribbon.trimTris && ribbon.trimUvs
|
||||
? {
|
||||
trimTrisBase64: base64FromFloats(ribbon.trimTris),
|
||||
trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: build.outline,
|
||||
caps: build.caps,
|
||||
structures: build.structures,
|
||||
planCurves: build.planCurves,
|
||||
cutWalls: (build.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positionsBase64: base64FromFloats(wall.positions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
return {
|
||||
ribbons: envelope.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positions: floatsFromBase64(ribbon.positionsBase64),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}),
|
||||
...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64
|
||||
? {
|
||||
trimTris: floatsFromBase64(ribbon.trimTrisBase64),
|
||||
trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
caps: envelope.caps ?? [],
|
||||
structures: envelope.structures ?? [],
|
||||
planCurves: envelope.planCurves ?? [],
|
||||
cutWalls: (envelope.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positions: floatsFromBase64(wall.positionsBase64),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCorridor(path: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
@@ -412,6 +101,19 @@ export type ProfileSamples = Array<{
|
||||
ground_elevation_m?: number;
|
||||
}>;
|
||||
|
||||
/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나.
|
||||
* 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다
|
||||
* (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */
|
||||
declare global {
|
||||
interface Window {
|
||||
__corridorSource?: { source: "stored" | "built"; hash: string; at: number };
|
||||
}
|
||||
}
|
||||
|
||||
function markSource(source: "stored" | "built", hash: string): void {
|
||||
window.__corridorSource = { source, hash, at: Date.now() };
|
||||
}
|
||||
|
||||
export async function ensureCorridor(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
@@ -429,6 +131,7 @@ export async function ensureCorridor(
|
||||
try {
|
||||
const build = deserialize(stored);
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
markSource("stored", hash);
|
||||
return build;
|
||||
} catch {
|
||||
// 손상 저장본 — 빌드로 폴백.
|
||||
@@ -447,6 +150,7 @@ export async function ensureCorridor(
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
markSource("built", hash);
|
||||
if (stored === null) {
|
||||
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Envelope.ts
|
||||
* 코리도 저장본 형식 — 버전·해시·직렬화 한 벌. **브라우저와 서버가 같은 것을 쓴다.**
|
||||
*
|
||||
* 서버(전처리 체인 끝)가 같은 빌더로 코리도를 미리 만들어 저장하므로(2026-09-04 사용자
|
||||
* 확정), 저장 형식과 버전 해시가 두 벌이 되면 브라우저가 서버 저장본을 남으로 보고
|
||||
* 통째로 다시 만든다. 그래서 이 파일 하나만 둔다 — 화면 API 를 쓰지 않아 Node 에서도
|
||||
* 그대로 돈다(`btoa`·`atob` 는 Node 16+ 전역).
|
||||
*
|
||||
* 원래 `_UI_Corridor.ts` 안에 있던 것을 그대로 옮겼다(2026-09-04 분리, 내용 불변).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CorridorBuildResult, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build_Types";
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures";
|
||||
|
||||
/**
|
||||
* 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다.
|
||||
*
|
||||
* 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다.
|
||||
*
|
||||
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
|
||||
* 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27.
|
||||
* 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자).
|
||||
* 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가
|
||||
* 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02).
|
||||
*/
|
||||
export const ENVELOPE_VERSION = 6;
|
||||
|
||||
export interface CorridorEnvelope {
|
||||
version: number;
|
||||
hash: string;
|
||||
ribbons: Array<{
|
||||
kind: CorridorRibbon["kind"];
|
||||
side: CorridorRibbon["side"];
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
positionsBase64: string;
|
||||
/**
|
||||
* 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도
|
||||
* 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연
|
||||
* 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다.
|
||||
*/
|
||||
patch?: boolean;
|
||||
patchClip?: boolean;
|
||||
/** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */
|
||||
cellMaskBase64?: string;
|
||||
/** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */
|
||||
trimTrisBase64?: string;
|
||||
trimUvsBase64?: string;
|
||||
}>;
|
||||
outline: CorridorBuildResult["outline"];
|
||||
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
|
||||
caps?: CorridorBuildResult["caps"];
|
||||
/** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */
|
||||
structures?: CorridorBuildResult["structures"];
|
||||
/**
|
||||
* 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면
|
||||
* 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다.
|
||||
*/
|
||||
planCurves?: CorridorBuildResult["planCurves"];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */
|
||||
cutWalls?: Array<{
|
||||
side: "left" | "right";
|
||||
positionsBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */
|
||||
function fnv1a(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.**
|
||||
*
|
||||
* 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아
|
||||
* 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다
|
||||
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
|
||||
* 섞어 두면 배포와 동시에 저장본이 만료된다.
|
||||
*/
|
||||
// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정·
|
||||
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
|
||||
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
|
||||
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
|
||||
// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게
|
||||
// 아니라 리본이 끝난 자리다(2026-08-27 사용자).
|
||||
// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) +
|
||||
// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정),
|
||||
// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토)
|
||||
// (2026-08-27 사용자).
|
||||
// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다
|
||||
// (2026-08-27 사용자).
|
||||
// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴
|
||||
// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자).
|
||||
// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인)
|
||||
// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반
|
||||
// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계
|
||||
// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형
|
||||
// 성토면이 있어야 한다 — 삭제가 아니다).
|
||||
// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서
|
||||
// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이
|
||||
// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복").
|
||||
// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다
|
||||
// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다),
|
||||
// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`,
|
||||
// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로.
|
||||
// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토
|
||||
// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자).
|
||||
// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라
|
||||
// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서
|
||||
// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자:
|
||||
// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림").
|
||||
// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`,
|
||||
// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로
|
||||
// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다
|
||||
// (2026-08-27 사용자: "좌측 스윕 더 가야 함").
|
||||
// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과
|
||||
// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.)
|
||||
// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단
|
||||
// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로").
|
||||
// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에
|
||||
// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.)
|
||||
// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`).
|
||||
// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다
|
||||
// (2026-08-27 사용자). 안쪽은 안 자른다.
|
||||
// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니
|
||||
// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로
|
||||
// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다
|
||||
// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지").
|
||||
// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거,
|
||||
// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고
|
||||
// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다
|
||||
// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다).
|
||||
// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은
|
||||
// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 —
|
||||
// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측).
|
||||
// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자).
|
||||
// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열
|
||||
// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는
|
||||
// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다**
|
||||
// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선
|
||||
// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다.
|
||||
// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥
|
||||
// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이
|
||||
// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해
|
||||
// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히
|
||||
// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이
|
||||
// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다").
|
||||
// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로
|
||||
// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지
|
||||
// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m).
|
||||
// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취
|
||||
// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다
|
||||
// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출").
|
||||
// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용).
|
||||
// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로
|
||||
// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선
|
||||
// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고
|
||||
// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음
|
||||
// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이
|
||||
// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가
|
||||
// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은
|
||||
// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다.
|
||||
// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로
|
||||
// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을
|
||||
// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다
|
||||
// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로
|
||||
// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다
|
||||
// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까").
|
||||
// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어
|
||||
// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**.
|
||||
// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다
|
||||
// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다.
|
||||
// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을
|
||||
// 가르고 **닫힌 고리만** 담는다.
|
||||
// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견
|
||||
// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자).
|
||||
// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤
|
||||
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
|
||||
// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2,
|
||||
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
|
||||
// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와
|
||||
// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자).
|
||||
// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만
|
||||
// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치
|
||||
// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취
|
||||
// 영역을 내므로 같은 반대 규칙이 성립한다.
|
||||
// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가
|
||||
// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆
|
||||
// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 —
|
||||
// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다.
|
||||
// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를
|
||||
// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길
|
||||
// 부재를 못 정하므로 만료한다.
|
||||
// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로
|
||||
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
||||
const BUILD_VERSION = 92;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
const parts: Array<string | number> = [`v${BUILD_VERSION}`, routePoints.length];
|
||||
routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2)));
|
||||
detail.cross_sections.forEach((section) => {
|
||||
const design = section.design;
|
||||
parts.push(
|
||||
section.chainage_m.toFixed(3),
|
||||
section.center_x.toFixed(2),
|
||||
section.center_y.toFixed(2),
|
||||
);
|
||||
if (!design) {
|
||||
parts.push("nodesign");
|
||||
return;
|
||||
}
|
||||
parts.push(
|
||||
design.design_elevation_m.toFixed(3),
|
||||
design.section_mode,
|
||||
design.ditch_side ?? "",
|
||||
design.ditch_type ?? "",
|
||||
String(design.ditch_enabled ?? ""),
|
||||
design.design_line.length,
|
||||
);
|
||||
design.design_line.forEach((point) =>
|
||||
parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)),
|
||||
);
|
||||
// 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다.
|
||||
structureHashParts(section).forEach((part) => parts.push(part));
|
||||
});
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
|
||||
function base64FromFloats(values: Float64Array): string {
|
||||
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(encoded: string): Uint8Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function floatsFromBase64(encoded: string): Float64Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Float64Array(bytes.buffer);
|
||||
}
|
||||
|
||||
export function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
return {
|
||||
version: ENVELOPE_VERSION,
|
||||
hash,
|
||||
ribbons: build.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positionsBase64: base64FromFloats(ribbon.positions),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}),
|
||||
...(ribbon.trimTris && ribbon.trimUvs
|
||||
? {
|
||||
trimTrisBase64: base64FromFloats(ribbon.trimTris),
|
||||
trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: build.outline,
|
||||
caps: build.caps,
|
||||
structures: build.structures,
|
||||
planCurves: build.planCurves,
|
||||
cutWalls: (build.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positionsBase64: base64FromFloats(wall.positions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
return {
|
||||
ribbons: envelope.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positions: floatsFromBase64(ribbon.positionsBase64),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}),
|
||||
...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64
|
||||
? {
|
||||
trimTris: floatsFromBase64(ribbon.trimTrisBase64),
|
||||
trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
caps: envelope.caps ?? [],
|
||||
structures: envelope.structures ?? [],
|
||||
planCurves: envelope.planCurves ?? [],
|
||||
cutWalls: (envelope.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positions: floatsFromBase64(wall.positionsBase64),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,11 @@ _FILE_TREES = (
|
||||
# 배수유역을 폴더 통째로 넓히기 전(2026-09-04)에 찍힌 스냅샷이 갖고 있는 자리.
|
||||
_LEGACY_DRAINAGE_TREE = "B04_PreProcess__drainage__edits"
|
||||
|
||||
# 코리도(3D 예상형상) 초기값 — 체인이 만든 저장본 한 벌(2026-09-04 사용자 확정).
|
||||
# 작업본 파일명에는 route id가 박히는데 [초기화]는 **새 route id**를 만드므로, 스냅샷에는
|
||||
# 번호 없는 이름으로 두었다가 복원 때 새 번호로 되돌린다.
|
||||
_CORRIDOR_NAME = "initial_corridor.json"
|
||||
|
||||
# `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556).
|
||||
_CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
|
||||
|
||||
@@ -198,6 +203,13 @@ async def save_initial_snapshot(
|
||||
for tree in _FILE_TREES:
|
||||
_copy_tree(root / tree, target / tree.replace("/", "__"))
|
||||
|
||||
# 코리도는 체인이 마지막에 만든다 — 있으면 함께 뜬다(없으면 브라우저 폴백 그대로).
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
||||
|
||||
corridor = corridor_path(root, route_id)
|
||||
if corridor.is_file():
|
||||
shutil.copy2(corridor, target / _CORRIDOR_NAME)
|
||||
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute("SELECT * FROM routes WHERE id = %s", (route_id,))
|
||||
route = await cursor.fetchone()
|
||||
@@ -245,7 +257,7 @@ def wipe_edited_masters(project_root: Path) -> list[str]:
|
||||
return removed
|
||||
|
||||
|
||||
def restore_snapshot_files(project_root: Path) -> None:
|
||||
def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> None:
|
||||
"""스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다.
|
||||
|
||||
배수유역 범위를 넓히기 전(2026-09-04)에 찍힌 스냅샷은 `edits/`만 갖고 있다 —
|
||||
@@ -261,6 +273,16 @@ def restore_snapshot_files(project_root: Path) -> None:
|
||||
continue
|
||||
_copy_tree(stored, root / tree)
|
||||
|
||||
# 코리도는 복원으로 만든 **새 route id** 이름으로 되돌린다 — 이름이 어긋나면 브라우저가
|
||||
# 저장본을 못 찾아 초기화 뒤 첫 진입마다 통째로 다시 만든다.
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
||||
|
||||
corridor = source / _CORRIDOR_NAME
|
||||
if route_id is not None and corridor.is_file():
|
||||
target = corridor_path(root, int(route_id))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(corridor, target)
|
||||
|
||||
|
||||
async def restore_initial_snapshot(
|
||||
connection: aiomysql.Connection, project_root: Path, project_id: str
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:b07-cad",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:b07-cad",
|
||||
"build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node",
|
||||
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
|
||||
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
|
||||
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
|
||||
|
||||
Reference in New Issue
Block a user