Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-06 22:22:44 +09:00
3 changed files with 90 additions and 35 deletions
+24 -5
View File
@@ -48,10 +48,12 @@ const DARK_VIEWER_BACKGROUND = 0x251f38;
* 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게).
* 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다.
*/
const cachedTerrain: { key: string | null; object: THREE.Object3D | null } = {
key: null,
object: null,
};
const cachedTerrain: {
key: string | null;
object: THREE.Object3D | null;
/** 높이 격자 색인 — 만드는 값이 O(삼각형)이라 지형과 한 벌로 쥔다. */
heightIndex: TerrainHeightIndex | null;
} = { key: null, object: null, heightIndex: null };
declare global {
interface Window {
@@ -231,6 +233,17 @@ export function createRouteViewer(): RouteViewer {
function terrainElevation(x: number, y: number): number | null {
if (!terrain || !bounds) return null;
const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds);
// 격자 색인으로 찾는다 — Raycaster 는 한 번 쏠 때마다 삼각형을 전부 훑는다.
// 마커·측점선이 점마다 부르는 자리라, B05 진입에서 **13.5초 중 11.6초**가 여기였다
// (2026-09-06 CPU 프로파일: getVertexPosition·intersectTriangle·checkGeometryIntersection).
// 같은 교훈을 2026-08-23 비탈 투영에서 이미 한 번 겪어 색인을 만들어 뒀다.
const index = ensureHeightIndex();
if (index) {
const height = index.heightAt(origin.x, origin.z);
return height === null
? null
: sceneToModel(new THREE.Vector3(origin.x, height, origin.z), bounds).z;
}
const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0));
const hit = raycaster.intersectObject(terrain, true)[0];
return hit ? sceneToModel(hit.point, bounds).z : null;
@@ -502,7 +515,12 @@ export function createRouteViewer(): RouteViewer {
function ensureHeightIndex(): TerrainHeightIndex | null {
if (heightIndex) return heightIndex;
if (!terrain) return null;
heightIndex = new TerrainHeightIndex(terrain);
// 보관해 둔 지형이면 색인도 같이 쓴다 — 화면을 드나들 때마다 다시 만들지 않는다.
heightIndex =
cachedTerrain.object === terrain && cachedTerrain.heightIndex
? cachedTerrain.heightIndex
: new TerrainHeightIndex(terrain);
if (cachedTerrain.object === terrain) cachedTerrain.heightIndex = heightIndex;
return heightIndex.triangleCount > 0 ? heightIndex : null;
}
@@ -679,6 +697,7 @@ export function createRouteViewer(): RouteViewer {
}
cachedTerrain.key = key;
cachedTerrain.object = terrain;
cachedTerrain.heightIndex = null; // 새 지형이면 색인도 새로.
}
scene.add(terrain);
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
+25 -19
View File
@@ -74,7 +74,7 @@ from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
@@ -93,20 +93,24 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
@router.get("/{project_id}/sections/context", response_model=SectionContextResponse)
async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse:
"""최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
# 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야
# 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화).
route_context = await get_workflow_route_context(connection, project_id)
surface_params = await get_surface_confirmation_params(connection, str(project_id))
# 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다.
# 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다.
async def _road_type(connection: aiomysql.Connection) -> str | None:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT road_type FROM projects WHERE id = %s", (str(project_id),)
)
row = await cursor.fetchone()
road_type = row[0] if row else None
return row[0] if row else None
# 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야
# 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화).
# 셋은 서로 기다릴 이유가 없다 — 원격 DB 왕복(약 12ms)이 더해지지 않게 같이 보낸다.
route_context, surface_params, road_type = await asyncio.gather(
run_with_connection(get_workflow_route_context, project_id),
run_with_connection(get_surface_confirmation_params, str(project_id)),
run_with_connection(_road_type),
)
defaults = SectionGenerationOptions()
return SectionContextResponse(
@@ -285,17 +289,19 @@ async def get_section_detail(
project_id: UUID, route_id: int
) -> SectionDetailResponse | JSONResponse:
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
if not longitudinal:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
stored_path = await get_project_storage_relative_path(connection, project_id)
designs = await get_cross_section_designs(connection, route_id)
# 서로 기다릴 이유가 없는 읽기 셋 — 원격 DB 라 순차로 내면 왕복이 그대로 더해진다
# (질의 하나 약 12ms, 2026-09-06 실측). 같이 보내 가장 느린 하나의 시간만 쓴다.
longitudinal, stored_path, designs = await asyncio.gather(
run_with_connection(get_longitudinal_section, project_id, route_id),
run_with_connection(get_project_storage_relative_path, project_id),
run_with_connection(get_cross_section_designs, route_id),
)
if not longitudinal:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
)
project_root = Path(resolve_stored_project_path(stored_path))
detail = await asyncio.to_thread(
_read_section_detail,
+41 -11
View File
@@ -31,6 +31,10 @@ export {
const SVG_NS = "http://www.w3.org/2000/svg";
/** 카드 버튼줄의 flex 간격(px) — 모든 카드가 같은 CSS 를 쓰므로 한 번만 잰다.
* `getComputedStyle` 도 강제 레이아웃을 부르므로 카드 67장마다 부르지 않는다. */
let barGapPx: number | null = null;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
@@ -413,21 +417,47 @@ export function buildDesignControls(
bar.append(slopeDirSeg, ...moveable, more);
const reflow = (): void => {
// 후보 전부 인라인 복귀 → more 숨김 → 넘치 뒤에서부터 패널로 이동.
// 후보 전부 인라인 복귀 → 폭을 **한 번만 재고** → 넘치는 만큼 뒤에서부터 패널로 이동.
//
// 예전에는 한 칸 옮길 때마다 `bar.scrollWidth` 를 다시 읽어(쓰기→읽기→쓰기) 브라우저가
// 매번 레이아웃을 강제로 다시 계산했다. 카드 67장마다 도는 자리라 B06 진입에서 일한
// 시간의 19.4% 를 이 함수가 썼다(2026-09-06 CPU 프로파일). 읽기와 쓰기를 갈랐다.
for (const element of moveable) bar.insertBefore(element, more);
morePanel.replaceChildren();
more.hidden = true;
if (bar.clientWidth <= 0) return;
for (
let index = moveable.length - 1;
index >= 0 && bar.scrollWidth > bar.clientWidth + 1;
index -= 1
) {
more.hidden = false;
morePanel.insertBefore(moveable[index], morePanel.firstChild);
more.hidden = false; // 폭을 재려면 자리에 있어야 한다.
const clientWidth = bar.clientWidth;
if (clientWidth <= 0) {
more.hidden = true;
return;
}
if (barGapPx === null) barGapPx = Number.parseFloat(getComputedStyle(bar).gap) || 0;
const widths = moveable.map((element) => element.offsetWidth);
let overflow = bar.scrollWidth - clientWidth;
let moveCount = 0;
while (overflow > 1 && moveCount < moveable.length) {
overflow -= widths[moveable.length - 1 - moveCount] + barGapPx;
moveCount += 1;
}
if (moveCount === 0) {
more.hidden = true;
return;
}
for (let index = 0; index < moveCount; index += 1) {
morePanel.insertBefore(moveable[moveable.length - 1 - index], morePanel.firstChild);
}
};
const overflowObserver = new ResizeObserver(() => reflow());
// 첫 호출은 아래 `requestAnimationFrame` 이 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번
// 더 불려 카드마다 reflow 가 두 번 돌았다.
let firstObservation = true;
const overflowObserver = new ResizeObserver(() => {
if (firstObservation) {
firstObservation = false;
return;
}
reflow();
});
overflowObserver.observe(bar);
requestAnimationFrame(reflow);