Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -37,6 +37,29 @@ type ViewKind = "iso" | "top" | "front" | "side";
|
||||
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
|
||||
const DARK_VIEWER_BACKGROUND = 0x251f38;
|
||||
|
||||
/**
|
||||
* 파싱해 둔 지표면 한 벌 — **뷰어보다 오래 산다**.
|
||||
*
|
||||
* B05 는 해시가 바뀔 때마다 `renderB05Route` 로 통째로 다시 조립되므로 뷰어 안의 상태는
|
||||
* 매번 비워진다. 그러면 8MB 짜리 지표면을 화면에 들어올 때마다 다시 읽고 다시 파싱하는데,
|
||||
* 실측에서 그 값이 **단일 동기 블록 14.4초**였다(2026-09-06 공용 브라우저 3왕복).
|
||||
* 같은 모델이면 이 자리에 둔 것을 새 장면에 그대로 붙인다.
|
||||
*
|
||||
* 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게).
|
||||
* 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다.
|
||||
*/
|
||||
const cachedTerrain: { key: string | null; object: THREE.Object3D | null } = {
|
||||
key: null,
|
||||
object: null,
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** 지표면 적재 단계별 시간(ms) — 화면 밖에서 수치로 확인하는 디버그 훅. */
|
||||
__surfaceTiming?: Array<{ step: string; ms: number }>;
|
||||
}
|
||||
}
|
||||
|
||||
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
|
||||
function countTriangles(root: THREE.Object3D | null): number {
|
||||
let total = 0;
|
||||
@@ -184,6 +207,9 @@ export function createRouteViewer(): RouteViewer {
|
||||
|
||||
let terrain: THREE.Object3D | null = null;
|
||||
// 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23).
|
||||
//
|
||||
// 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시
|
||||
// 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다).
|
||||
let corridorBuild: CorridorBuildResult | null = null;
|
||||
let corridorGroup: THREE.Group | null = null;
|
||||
let clippedTerrain: THREE.Object3D | null = null;
|
||||
@@ -609,38 +635,66 @@ export function createRouteViewer(): RouteViewer {
|
||||
markers,
|
||||
structurePick,
|
||||
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
|
||||
const step = (label: string, from: number): void => {
|
||||
timing.push({ step: label, ms: Math.round(performance.now() - from) });
|
||||
};
|
||||
const timing: Array<{ step: string; ms: number }> = [];
|
||||
const started = performance.now();
|
||||
bounds = nextBounds;
|
||||
current = { projectId, modelId, smooth, interval };
|
||||
if (terrain) {
|
||||
scene.remove(terrain);
|
||||
disposeObject(terrain);
|
||||
if (terrain !== cachedTerrain.object) disposeObject(terrain);
|
||||
}
|
||||
heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다.
|
||||
bandSplit?.dispose();
|
||||
bandSplit = null;
|
||||
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
|
||||
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
|
||||
const buffer = await fetchCachedBytes(projectId, url);
|
||||
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
if (method === "meshfree") {
|
||||
const geometry = new PLYLoader().parse(buffer);
|
||||
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
|
||||
} else {
|
||||
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
|
||||
const key = `${projectId}:${modelId}:${method}:${smooth}`;
|
||||
if (cachedTerrain.key === key && cachedTerrain.object) {
|
||||
// 같은 지표면 모델을 이미 파싱해 뒀다 — 다시 읽지도 파싱하지도 않는다(2026-09-06).
|
||||
terrain = cachedTerrain.object;
|
||||
step("reuse", started);
|
||||
} else {
|
||||
const fetched = performance.now();
|
||||
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
|
||||
const buffer = await fetchCachedBytes(projectId, url);
|
||||
step("fetch", fetched);
|
||||
const parsed = performance.now();
|
||||
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
if (method === "meshfree") {
|
||||
const geometry = new PLYLoader().parse(buffer);
|
||||
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
|
||||
} else {
|
||||
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
|
||||
}
|
||||
});
|
||||
terrain.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
|
||||
});
|
||||
step("parse", parsed);
|
||||
// 한 벌만 쥔다 — 다른 모델로 바뀌면 옛것을 버린다.
|
||||
if (cachedTerrain.object && cachedTerrain.object !== terrain) {
|
||||
disposeObject(cachedTerrain.object);
|
||||
}
|
||||
});
|
||||
terrain.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
|
||||
});
|
||||
cachedTerrain.key = key;
|
||||
cachedTerrain.object = terrain;
|
||||
}
|
||||
scene.add(terrain);
|
||||
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
|
||||
applySurfaceGrayscale();
|
||||
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
|
||||
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
|
||||
if (corridorBuild) setCorridor(corridorBuild);
|
||||
const fitted = performance.now();
|
||||
fit("top");
|
||||
markers.renderMarkers();
|
||||
step("fit+markers", fitted);
|
||||
const contoured = performance.now();
|
||||
await reloadContours(interval);
|
||||
step("contours", contoured);
|
||||
step("total", started);
|
||||
window.__surfaceTiming = timing;
|
||||
// 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
|
||||
// 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다.
|
||||
status.textContent = "";
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""측점 설계를 **여러 행 한 번에** 쓰는 자리.
|
||||
|
||||
왜 (2026-09-06 실측) — [저장]이 측점마다 `update_cross_section_design` ·
|
||||
`merge_cross_section_design_patch` 를 불렀고, 그 하나가 `SELECT` + `UPDATE` 두 왕복이다.
|
||||
DB 가 원격(`dsm.chemifactory.com`)이라 왕복 하나가 **약 12ms** 다. 22행이면 왕복 44번,
|
||||
곧 **670ms**. 측점이 많은 프로젝트일수록 선형으로 늘어난다.
|
||||
|
||||
여기서는 세 문장으로 끝낸다 —
|
||||
① 노선의 측점 행을 **한 번에** 읽고
|
||||
② 파이썬에서 chainage 를 맞춰 JSON 을 합치고
|
||||
③ `UPDATE … SET data = CASE id …` 한 문장으로 되돌려 쓴다(없는 행은 다중 INSERT).
|
||||
|
||||
`B06_Section_Repository` 가 685줄이라 700줄 한계에 걸려 파일을 나눴다. 한 행짜리 함수는
|
||||
그쪽에 그대로 두고, 여러 행을 쓸 때만 이쪽을 쓴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
|
||||
# 측점을 같은 자리로 볼 허용 오차(m) — 한 행짜리 함수와 같은 값을 쓴다.
|
||||
_CHAINAGE_TOLERANCE_M = 0.01
|
||||
|
||||
|
||||
async def merge_cross_section_designs(
|
||||
connection: aiomysql.Connection,
|
||||
*,
|
||||
route_id: int,
|
||||
entries: list[tuple[float, dict[str, Any]]],
|
||||
replace: bool,
|
||||
project_id: UUID | None = None,
|
||||
) -> int:
|
||||
"""측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다.
|
||||
|
||||
`replace=True` 면 design 을 통째로 갈아 끼우고(`update_cross_section_design` 과 같은 뜻),
|
||||
`False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻).
|
||||
|
||||
행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이
|
||||
파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙).
|
||||
"""
|
||||
if not entries:
|
||||
return 0
|
||||
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT id, chainage_m, data FROM cross_sections WHERE route_id = %s ORDER BY id",
|
||||
(route_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
existing: dict[int, dict[str, Any]] = {}
|
||||
for row_id, _chainage, raw in rows:
|
||||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
existing[int(row_id)] = data if isinstance(data, dict) else {}
|
||||
# 같은 측점이 여러 행이면 뒤에 온 것(=큰 id)을 쓴다 — 한 행짜리 함수의 `ORDER BY id DESC`.
|
||||
ordered = [(float(chainage), int(row_id)) for row_id, chainage, _ in rows]
|
||||
|
||||
def find(chainage_m: float) -> int | None:
|
||||
best: int | None = None
|
||||
for value, row_id in ordered:
|
||||
if abs(value - chainage_m) < _CHAINAGE_TOLERANCE_M and (best is None or row_id > best):
|
||||
best = row_id
|
||||
return best
|
||||
|
||||
updates: list[tuple[int, str]] = []
|
||||
inserts: list[tuple[str, int, float, str]] = []
|
||||
for chainage_m, payload in entries:
|
||||
if not payload and not replace:
|
||||
continue
|
||||
row_id = find(chainage_m)
|
||||
if row_id is None:
|
||||
if project_id is None:
|
||||
continue
|
||||
inserts.append(
|
||||
(
|
||||
str(project_id),
|
||||
route_id,
|
||||
chainage_m,
|
||||
json.dumps({"design": payload}, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
continue
|
||||
data = dict(existing[row_id])
|
||||
if replace:
|
||||
data["design"] = payload
|
||||
else:
|
||||
design = data.get("design")
|
||||
design = dict(design) if isinstance(design, dict) else {}
|
||||
design.update(payload)
|
||||
data["design"] = design
|
||||
updates.append((row_id, json.dumps(data, ensure_ascii=False)))
|
||||
|
||||
written = 0
|
||||
async with connection.cursor() as cursor:
|
||||
if updates:
|
||||
# 한 문장 — `CASE id WHEN … THEN …` 이라 왕복이 한 번이다.
|
||||
cases = " ".join("WHEN %s THEN %s" for _ in updates)
|
||||
params: list[Any] = []
|
||||
for row_id, blob in updates:
|
||||
params.extend((row_id, blob))
|
||||
params.extend(row_id for row_id, _ in updates)
|
||||
placeholders = ", ".join("%s" for _ in updates)
|
||||
await cursor.execute(
|
||||
f"UPDATE cross_sections SET data = CASE id {cases} END "
|
||||
f"WHERE id IN ({placeholders})",
|
||||
params,
|
||||
)
|
||||
written += len(updates)
|
||||
if inserts:
|
||||
values = ", ".join("(%s, %s, %s, %s, 'DRAFT')" for _ in inserts)
|
||||
flat: list[Any] = []
|
||||
for item in inserts:
|
||||
flat.extend(item)
|
||||
await cursor.execute(
|
||||
"INSERT INTO cross_sections (project_id, route_id, chainage_m, data, status) "
|
||||
f"VALUES {values}",
|
||||
flat,
|
||||
)
|
||||
written += len(inserts)
|
||||
return written
|
||||
@@ -28,11 +28,10 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_cross_sections_missing_design_chainages,
|
||||
get_longitudinal_section,
|
||||
merge_cross_section_design_patch,
|
||||
merge_longitudinal_section_data,
|
||||
merge_longitudinal_section_options,
|
||||
update_cross_section_design,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
|
||||
# 원본은 `B06_Section_Router_Design` 이다 — `B06_Section_Router` 를 거쳐 들여오던 것을
|
||||
# 곧바로 잇는다(2026-09-06). 그 재수출이 없어지면서 서버가 뜨지 못했다.
|
||||
@@ -95,14 +94,14 @@ async def _apply_section_edits(
|
||||
project_id: UUID | None = None,
|
||||
) -> None:
|
||||
"""임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다."""
|
||||
for chainage_m, design in default_designs:
|
||||
await update_cross_section_design(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage_m=chainage_m,
|
||||
design=design,
|
||||
project_id=project_id,
|
||||
)
|
||||
# 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다.
|
||||
await merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
entries=list(default_designs),
|
||||
replace=True,
|
||||
project_id=project_id,
|
||||
)
|
||||
if request and request.standard_cross_section:
|
||||
await merge_longitudinal_section_options(
|
||||
connection,
|
||||
@@ -116,6 +115,7 @@ async def _apply_section_edits(
|
||||
)
|
||||
# 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합.
|
||||
if request and request.cross_patches:
|
||||
patches: list[tuple[float, dict[str, Any]]] = []
|
||||
for patch_item in request.cross_patches:
|
||||
patch: dict[str, Any] = {}
|
||||
if patch_item.rock_boundary_offset_m is not None:
|
||||
@@ -163,9 +163,11 @@ async def _apply_section_edits(
|
||||
if value is not None:
|
||||
patch[area_key] = value
|
||||
if patch:
|
||||
await merge_cross_section_design_patch(
|
||||
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
|
||||
)
|
||||
patches.append((patch_item.chainage_m, patch))
|
||||
# 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다.
|
||||
await merge_cross_section_designs(
|
||||
connection, route_id=route_id, entries=patches, replace=False
|
||||
)
|
||||
|
||||
|
||||
async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
|
||||
|
||||
@@ -32,10 +32,9 @@ from uuid import UUID
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_longitudinal_section,
|
||||
merge_cross_section_design_patch,
|
||||
merge_longitudinal_section_data,
|
||||
update_cross_section_design,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
@@ -153,29 +152,26 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
for item in fixed:
|
||||
await update_cross_section_design(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage_m=float(item.get("chainage_m") or 0.0),
|
||||
design=item["design"],
|
||||
project_id=project_uuid,
|
||||
)
|
||||
# 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다.
|
||||
await merge_cross_section_designs(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
entries=[(float(item.get("chainage_m") or 0.0), item["design"]) for item in fixed],
|
||||
replace=True,
|
||||
project_id=project_uuid,
|
||||
)
|
||||
area_entries: list[tuple[float, dict[str, Any]]] = []
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
patch: dict[str, Any] = {
|
||||
key: float(row[key])
|
||||
for key in _AREA_KEYS
|
||||
if isinstance(row.get(key), (int, float))
|
||||
}
|
||||
if not patch:
|
||||
continue
|
||||
if await merge_cross_section_design_patch(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage_m=float(row["chainage_m"]),
|
||||
patch=patch,
|
||||
):
|
||||
updated += 1
|
||||
if patch:
|
||||
area_entries.append((float(row["chainage_m"]), patch))
|
||||
updated = await merge_cross_section_designs(
|
||||
connection, route_id=route_id, entries=area_entries, replace=False
|
||||
)
|
||||
if isinstance(mass_haul, dict):
|
||||
await merge_longitudinal_section_data(
|
||||
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
|
||||
|
||||
Reference in New Issue
Block a user