sync: 4환경 수렴본 main 반영 (PR #10)
4환경 브랜치 전부fa0624a7/ tree95d6e89b동일. 커밋 34건. 테스트 359 passed, 17 skipped, 0 failed.
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면
|
||||
# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫.
|
||||
B07_DesignDetail/openwebcad/
|
||||
|
||||
# 빌드·산출물·가상환경 — 포맷 대상이 아니다.
|
||||
dist/
|
||||
venv/
|
||||
storage/
|
||||
tmp/
|
||||
graphify-out/
|
||||
@@ -43,6 +43,17 @@ export interface ProjectItem {
|
||||
estimated_length_m?: number | null;
|
||||
memo?: string | null;
|
||||
status?: string | null;
|
||||
/** 도면 표제란·표지에 실리는 값 — 프로그램이 지어낼 수 없어 사람이 넣는다 */
|
||||
client_org?: string | null;
|
||||
project_number?: string | null;
|
||||
work_amount?: string | null;
|
||||
design_date?: string | null;
|
||||
/** 담당자는 회사 구성원(users.id), 로고·서명은 회사 공유 자산(company_assets.id) */
|
||||
pm_user_id?: number | null;
|
||||
field_lead_user_id?: number | null;
|
||||
designer_user_id?: number | null;
|
||||
logo_asset_id?: number | null;
|
||||
signature_asset_id?: number | null;
|
||||
owner_name?: string | null;
|
||||
workflow_stage: number;
|
||||
progress_percent: number;
|
||||
@@ -71,6 +82,16 @@ export interface Member {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** 회사 안에서 공유하는 도면 자산(로고·서명). user_id 가 없으면 회사 공용. */
|
||||
export interface CompanyAsset {
|
||||
id: number;
|
||||
company_id: number;
|
||||
kind: "LOGO" | "SIGNATURE";
|
||||
label: string;
|
||||
user_id?: number | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: number;
|
||||
user_id: number;
|
||||
@@ -132,6 +153,15 @@ export interface UpdateProjectRequest {
|
||||
estimated_length_m?: number | null;
|
||||
memo?: string | null;
|
||||
status?: string | null;
|
||||
client_org?: string | null;
|
||||
project_number?: string | null;
|
||||
work_amount?: string | null;
|
||||
design_date?: string | null;
|
||||
pm_user_id?: number | null;
|
||||
field_lead_user_id?: number | null;
|
||||
designer_user_id?: number | null;
|
||||
logo_asset_id?: number | null;
|
||||
signature_asset_id?: number | null;
|
||||
}
|
||||
|
||||
export interface AdminUpdateUserRequest extends UpdateUserRequest {
|
||||
@@ -217,11 +247,43 @@ export function joinCompany(companyId: number): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCompanyMembers(): Promise<Member[]> {
|
||||
const data = await request<{ members: Member[] }>("/dashboard/admin/members");
|
||||
const companyQuery = (companyId?: number | null): string =>
|
||||
companyId ? `?company_id=${encodeURIComponent(companyId)}` : "";
|
||||
|
||||
/** companyId 는 시스템관리자가 남의 회사 프로젝트를 고칠 때만 넘긴다. */
|
||||
export async function fetchCompanyMembers(companyId?: number | null): Promise<Member[]> {
|
||||
const data = await request<{ members: Member[] }>(
|
||||
`/dashboard/admin/members${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.members;
|
||||
}
|
||||
|
||||
export async function fetchCompanyAssets(companyId?: number | null): Promise<CompanyAsset[]> {
|
||||
const data = await request<{ assets: CompanyAsset[] }>(
|
||||
`/dashboard/company/assets${companyQuery(companyId)}`,
|
||||
);
|
||||
return data.assets;
|
||||
}
|
||||
|
||||
/** multipart 업로드라 `request()`의 JSON 헤더를 쓰지 않는다. */
|
||||
export async function createCompanyAsset(form: FormData): Promise<number> {
|
||||
const response = await fetch(`${API_BASE_URL}/dashboard/company/assets`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
const data = (await response.json()) as { detail?: string; asset_id: number };
|
||||
if (!response.ok) throw new Error(data.detail ?? "Request failed");
|
||||
return data.asset_id;
|
||||
}
|
||||
|
||||
export function deleteCompanyAsset(assetId: number): Promise<unknown> {
|
||||
return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export const companyAssetFileUrl = (assetId: number): string =>
|
||||
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
|
||||
|
||||
export function addCompanyMember(email: string): Promise<unknown> {
|
||||
return request("/dashboard/admin/members", { method: "POST", body: body({ email }) });
|
||||
}
|
||||
|
||||
@@ -45,6 +45,16 @@ def _project_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**row, "workflow_stage": stage, "progress_percent": progress}
|
||||
|
||||
|
||||
async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows])
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
|
||||
|
||||
async def get_dashboard_me(user_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
@@ -148,20 +158,15 @@ async def list_user_projects(user_id: int) -> list[dict[str, Any]]:
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id, name, region, road_type, project_year,
|
||||
estimated_length_m, memo, status, updated_at, created_at
|
||||
estimated_length_m, memo, status, updated_at, created_at,
|
||||
client_org, project_number, work_amount, design_date,
|
||||
pm_user_id, field_lead_user_id, designer_user_id,
|
||||
logo_asset_id, signature_asset_id
|
||||
FROM projects WHERE user_id = %s AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC, created_at DESC""",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
pids = [r["id"] for r in rows]
|
||||
states = await get_workflow_states_for_projects(cursor, pids)
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
return await _project_rows(cursor, await cursor.fetchall())
|
||||
|
||||
|
||||
async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
@@ -170,21 +175,16 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
|
||||
await cursor.execute(
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
|
||||
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
|
||||
p.client_org, p.project_number, p.work_amount, p.design_date,
|
||||
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
|
||||
p.logo_asset_id, p.signature_asset_id,
|
||||
u.name AS owner_name, u.email AS owner_email
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.company_id = %s AND p.deleted_at IS NULL
|
||||
ORDER BY p.updated_at DESC, p.created_at DESC""",
|
||||
(company_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
pids = [r["id"] for r in rows]
|
||||
states = await get_workflow_states_for_projects(cursor, pids)
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
return await _project_rows(cursor, await cursor.fetchall())
|
||||
|
||||
|
||||
async def list_all_projects() -> list[dict[str, Any]]:
|
||||
@@ -193,20 +193,15 @@ async def list_all_projects() -> list[dict[str, Any]]:
|
||||
await cursor.execute(
|
||||
"""SELECT p.id, p.company_id, p.name, p.region, p.road_type, p.project_year,
|
||||
p.estimated_length_m, p.memo, p.status, p.updated_at, p.created_at,
|
||||
p.client_org, p.project_number, p.work_amount, p.design_date,
|
||||
p.pm_user_id, p.field_lead_user_id, p.designer_user_id,
|
||||
p.logo_asset_id, p.signature_asset_id,
|
||||
u.name AS owner_name, u.email AS owner_email
|
||||
FROM projects p LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
ORDER BY p.updated_at DESC, p.created_at DESC"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
pids = [r["id"] for r in rows]
|
||||
states = await get_workflow_states_for_projects(cursor, pids)
|
||||
result = []
|
||||
for r in rows:
|
||||
p_row = _project_row(r)
|
||||
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
|
||||
result.append(p_row)
|
||||
return result
|
||||
return await _project_rows(cursor, await cursor.fetchall())
|
||||
|
||||
|
||||
async def get_project(project_id: str) -> dict[str, Any] | None:
|
||||
@@ -214,7 +209,10 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, user_id, company_id, name, region, road_type, project_year,
|
||||
estimated_length_m, memo, status
|
||||
estimated_length_m, memo, status,
|
||||
client_org, project_number, work_amount, design_date,
|
||||
pm_user_id, field_lead_user_id, designer_user_id,
|
||||
logo_asset_id, signature_asset_id
|
||||
FROM projects WHERE id = %s AND deleted_at IS NULL""",
|
||||
(project_id,),
|
||||
)
|
||||
@@ -228,7 +226,10 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
|
||||
await cursor.execute(
|
||||
"""UPDATE projects
|
||||
SET name = %s, region = %s, road_type = %s, project_year = %s,
|
||||
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status)
|
||||
estimated_length_m = %s, memo = %s, status = COALESCE(%s, status),
|
||||
client_org = %s, project_number = %s, work_amount = %s,
|
||||
design_date = %s, pm_user_id = %s, field_lead_user_id = %s,
|
||||
designer_user_id = %s, logo_asset_id = %s, signature_asset_id = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(
|
||||
data["name"],
|
||||
@@ -238,6 +239,15 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
|
||||
data.get("estimated_length_m"),
|
||||
data.get("memo"),
|
||||
data.get("status"),
|
||||
data.get("client_org"),
|
||||
data.get("project_number"),
|
||||
data.get("work_amount"),
|
||||
data.get("design_date"),
|
||||
data.get("pm_user_id"),
|
||||
data.get("field_lead_user_id"),
|
||||
data.get("designer_user_id"),
|
||||
data.get("logo_asset_id"),
|
||||
data.get("signature_asset_id"),
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""B01_Dashboard 회사 공유 도면 자산(로고·서명) 저장소 — `company_assets` (013).
|
||||
|
||||
그림은 `storage/{회사}/assets/` 에 파일로 두고 표에는 경로만 담는다. 자산은 사용자 계정에
|
||||
물릴 수도(개인 서명) 아닐 수도(회사 공용 직인) 있다 — `user_id` NULL 이 공용.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
|
||||
ASSET_KINDS = ("LOGO", "SIGNATURE")
|
||||
ASSET_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||
ASSET_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
async def list_company_assets(company_id: int) -> list[dict[str, Any]]:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT a.id, a.company_id, a.kind, a.label, a.file_path, a.user_id,
|
||||
u.name AS user_name, a.created_at
|
||||
FROM company_assets a LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.company_id = %s AND a.deleted_at IS NULL
|
||||
ORDER BY a.kind, a.label, a.id""",
|
||||
(company_id,),
|
||||
)
|
||||
return list(await cursor.fetchall())
|
||||
|
||||
|
||||
async def get_company_asset(asset_id: int) -> dict[str, Any] | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT id, company_id, kind, label, file_path, user_id
|
||||
FROM company_assets WHERE id = %s AND deleted_at IS NULL""",
|
||||
(asset_id,),
|
||||
)
|
||||
return await cursor.fetchone()
|
||||
|
||||
|
||||
def write_company_asset_file(company_id: int, kind: str, suffix: str, blob: bytes) -> str:
|
||||
"""그림을 `storage/{회사}/assets/` 에 쓰고 storage 기준 상대 경로를 돌려준다."""
|
||||
folder = os.path.join(STORAGE_BASE_DIR, str(company_id), "assets")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
filename = f"{kind.lower()}_{uuid.uuid4().hex}{suffix}"
|
||||
with open(os.path.join(folder, filename), "wb") as file:
|
||||
file.write(blob)
|
||||
return f"storage/{company_id}/assets/{filename}"
|
||||
|
||||
|
||||
async def create_company_asset(
|
||||
company_id: int, kind: str, label: str, file_path: str, user_id: int | None, actor_id: int
|
||||
) -> int:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""INSERT INTO company_assets (company_id, kind, label, file_path, user_id, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(company_id, kind, label, file_path, user_id, actor_id),
|
||||
)
|
||||
await connection.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
async def update_company_asset(asset_id: int, label: str, user_id: int | None) -> bool:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE company_assets SET label = %s, user_id = %s
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(label, user_id, asset_id),
|
||||
)
|
||||
await connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def delete_company_asset(asset_id: int) -> bool:
|
||||
"""소프트 삭제 — 프로젝트가 물고 있던 참조는 도면 읽을 때 LEFT JOIN 으로 빈칸이 된다."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""UPDATE company_assets SET deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND deleted_at IS NULL""",
|
||||
(asset_id,),
|
||||
)
|
||||
await connection.commit()
|
||||
return cursor.rowcount > 0
|
||||
@@ -1,11 +1,14 @@
|
||||
"""B01_Dashboard 역할별 대시보드 API."""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
|
||||
|
||||
from common_util.common_util_auth import require_system_admin, verify_session
|
||||
from common_util.common_util_project_delete import hard_delete_project
|
||||
from common_util.common_util_storage import read_stored_asset
|
||||
from config.config_system import PROJECT_DELETE_HARD_ENABLED
|
||||
|
||||
from .B01_Dashboard_Repository import (
|
||||
@@ -35,6 +38,17 @@ from .B01_Dashboard_Repository import (
|
||||
update_project,
|
||||
update_user_profile,
|
||||
)
|
||||
from .B01_Dashboard_Repository_Assets import (
|
||||
ASSET_EXTENSIONS,
|
||||
ASSET_KINDS,
|
||||
ASSET_MAX_BYTES,
|
||||
create_company_asset,
|
||||
delete_company_asset,
|
||||
get_company_asset,
|
||||
list_company_assets,
|
||||
update_company_asset,
|
||||
write_company_asset_file,
|
||||
)
|
||||
from .B01_Dashboard_Schema import (
|
||||
AddMemberRequest,
|
||||
AdminUpdateUserRequest,
|
||||
@@ -43,6 +57,7 @@ from .B01_Dashboard_Schema import (
|
||||
CreateCompanyRequest,
|
||||
JoinCompanyRequest,
|
||||
ProcessJoinRequest,
|
||||
UpdateCompanyAssetRequest,
|
||||
UpdateProjectRequest,
|
||||
UpdateUserRequest,
|
||||
)
|
||||
@@ -69,6 +84,43 @@ def _same_company(session: dict[str, Any], company_id: int | None) -> bool:
|
||||
return company_id is not None and int(session.get("company_id") or 0) == int(company_id)
|
||||
|
||||
|
||||
def _scope_company(session: dict[str, Any], company_id: int | None) -> int:
|
||||
"""회사 범위 자원(구성원·로고·서명)의 회사. 시스템관리자만 남의 회사를 지정할 수 있다."""
|
||||
if session["role"] == "SYSTEM_ADMIN" and company_id is not None:
|
||||
return int(company_id)
|
||||
own = _require_company_id(session)
|
||||
if company_id is not None and int(company_id) != own:
|
||||
raise HTTPException(status_code=403, detail="다른 회사의 자료는 볼 수 없습니다.")
|
||||
return own
|
||||
|
||||
|
||||
async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, Any]:
|
||||
asset = await get_company_asset(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="자산을 찾을 수 없습니다.")
|
||||
_scope_company(session, int(asset["company_id"]))
|
||||
return asset
|
||||
|
||||
|
||||
async def _check_project_refs(project: dict[str, Any], data: dict[str, Any]) -> None:
|
||||
"""담당자·로고·서명은 프로젝트 회사의 것만 물린다 (2026-09-02 사용자 확정)."""
|
||||
company_id = int(project["company_id"])
|
||||
user_ids = {
|
||||
data[k] for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data[k]
|
||||
}
|
||||
if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}:
|
||||
raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.")
|
||||
wanted = {
|
||||
k: kind
|
||||
for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE"))
|
||||
if data[k]
|
||||
}
|
||||
if wanted:
|
||||
kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)}
|
||||
if any(kinds.get(data[k]) != kind for k, kind in wanted.items()):
|
||||
raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.")
|
||||
|
||||
|
||||
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
|
||||
if session["role"] == "SYSTEM_ADMIN":
|
||||
return True
|
||||
@@ -142,10 +194,14 @@ async def user_company_join(
|
||||
|
||||
|
||||
@router.get("/admin/members")
|
||||
async def admin_members(session: dict[str, Any] = Depends(require_company_admin)):
|
||||
async def admin_members(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(require_company_admin),
|
||||
):
|
||||
# company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다.
|
||||
return {
|
||||
"status": "success",
|
||||
"members": await list_company_members(_require_company_id(session)),
|
||||
"members": await list_company_members(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +267,9 @@ async def dashboard_update_project(
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
if not _can_edit_project(session, project):
|
||||
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
|
||||
if not await update_project(project_id, payload.model_dump(), int(session["user_id"])):
|
||||
data = payload.model_dump()
|
||||
await _check_project_refs(project, data)
|
||||
if not await update_project(project_id, data, int(session["user_id"])):
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
|
||||
return {"status": "success"}
|
||||
|
||||
@@ -352,3 +410,77 @@ async def system_resources(
|
||||
async def system_projects(session: dict[str, Any] = Depends(require_system_admin)):
|
||||
_ = session
|
||||
return {"status": "success", "projects": await list_all_projects()}
|
||||
|
||||
|
||||
# ---- 회사 공유 도면 자산(로고·서명) — 회사 구성원 누구나 작성·수정, 시스템관리자는 전체 ----
|
||||
|
||||
|
||||
@router.get("/company/assets")
|
||||
async def company_assets(
|
||||
company_id: int | None = Query(None, gt=0),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
return {
|
||||
"status": "success",
|
||||
"assets": await list_company_assets(_scope_company(session, company_id)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/company/assets")
|
||||
async def company_add_asset(
|
||||
kind: str = Form(...),
|
||||
label: str = Form(...),
|
||||
user_id: int | None = Form(None, gt=0),
|
||||
company_id: int | None = Form(None, gt=0),
|
||||
file: UploadFile = File(...),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
kind = kind.upper()
|
||||
if kind not in ASSET_KINDS:
|
||||
raise HTTPException(status_code=400, detail="자산 종류는 LOGO 또는 SIGNATURE 입니다.")
|
||||
label = label.strip()[:100]
|
||||
if not label:
|
||||
raise HTTPException(status_code=400, detail="이름을 넣어 주세요.")
|
||||
suffix = os.path.splitext(file.filename or "")[1].lower()
|
||||
if suffix not in ASSET_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail="png·jpg·webp·svg 그림만 올릴 수 있습니다.")
|
||||
blob = await file.read()
|
||||
if not blob or len(blob) > ASSET_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="그림은 2MB 이하여야 합니다.")
|
||||
scoped = _scope_company(session, company_id)
|
||||
if user_id and user_id not in {m["id"] for m in await list_company_members(scoped)}:
|
||||
raise HTTPException(status_code=400, detail="주인은 같은 회사 구성원이어야 합니다.")
|
||||
path = write_company_asset_file(scoped, kind, suffix, blob)
|
||||
asset_id = await create_company_asset(
|
||||
scoped, kind, label, path, user_id, int(session["user_id"])
|
||||
)
|
||||
return {"status": "success", "asset_id": asset_id}
|
||||
|
||||
|
||||
@router.put("/company/assets/{asset_id}")
|
||||
async def company_update_asset(
|
||||
asset_id: int,
|
||||
payload: UpdateCompanyAssetRequest,
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
await _company_asset(session, asset_id)
|
||||
await update_company_asset(asset_id, payload.label.strip(), payload.user_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.delete("/company/assets/{asset_id}")
|
||||
async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
await _company_asset(session, asset_id)
|
||||
await delete_company_asset(asset_id)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/company/assets/{asset_id}/file")
|
||||
async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)):
|
||||
"""목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다."""
|
||||
asset = await _company_asset(session, asset_id)
|
||||
blob = read_stored_asset(asset["file_path"])
|
||||
if blob is None:
|
||||
raise HTTPException(status_code=404, detail="그림 파일이 없습니다.")
|
||||
media_type = mimetypes.guess_type(asset["file_path"])[0] or "application/octet-stream"
|
||||
return Response(content=blob, media_type=media_type)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""B01_Dashboard 요청 스키마."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -46,9 +48,28 @@ class UpdateProjectRequest(BaseModel):
|
||||
estimated_length_m: float | None = Field(default=None, ge=0)
|
||||
memo: str | None = Field(default=None, max_length=5000)
|
||||
status: str | None = Field(default=None, max_length=50)
|
||||
# 도면 표제란·표지에 실리는 값 (2026-09-02). 프로그램이 지어낼 수 없어 사람이 넣는다.
|
||||
client_org: str | None = Field(default=None, max_length=255)
|
||||
project_number: str | None = Field(default=None, max_length=100)
|
||||
work_amount: str | None = Field(default=None, max_length=100)
|
||||
# 설계일자는 확정일 자동이 아니라 사용자 지정이다 (2026-09-02 사용자 확정).
|
||||
design_date: date | None = None
|
||||
# 담당자는 회사 구성원(users) 중에서, 로고·서명은 회사 공유 자산(company_assets) 중에서
|
||||
# 고른다 — 같은 회사 것인지는 라우터가 확인한다.
|
||||
pm_user_id: int | None = Field(default=None, gt=0)
|
||||
field_lead_user_id: int | None = Field(default=None, gt=0)
|
||||
designer_user_id: int | None = Field(default=None, gt=0)
|
||||
logo_asset_id: int | None = Field(default=None, gt=0)
|
||||
signature_asset_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class AdminUpdateUserRequest(UpdateUserRequest):
|
||||
status: str | None = Field(
|
||||
default=None, pattern="^(NO_COMPANY|PENDING|ACTIVE|INACTIVE|REJECTED)$"
|
||||
)
|
||||
|
||||
|
||||
class UpdateCompanyAssetRequest(BaseModel):
|
||||
label: str = Field(min_length=1, max_length=100)
|
||||
# 사용자 계정에 물릴 수도(개인 서명) 아닐 수도(회사 공용 직인) 있다.
|
||||
user_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 회사 공유 도면 자산(로고·서명) 고르기 (2026-09-02 사용자 확정).
|
||||
*
|
||||
* 프로젝트 수정 모달의 로고·서명 칸을 누르면 등록된 목록을 모달로 보여 주고, 없으면
|
||||
* 그 자리에서 새로 올린다. 자산은 회사 안에서 공유하며 사용자 계정에 물릴 수도(개인 서명)
|
||||
* 아닐 수도(회사 공용 직인) 있다.
|
||||
*/
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
companyAssetFileUrl,
|
||||
createCompanyAsset,
|
||||
deleteCompanyAsset,
|
||||
fetchCompanyAssets,
|
||||
type CompanyAsset,
|
||||
type DashboardUser,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
export interface AssetFieldHandle {
|
||||
root: HTMLDivElement;
|
||||
value: () => number | null;
|
||||
}
|
||||
|
||||
const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const;
|
||||
|
||||
/** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */
|
||||
export function createAssetField(
|
||||
label: string,
|
||||
kind: CompanyAsset["kind"],
|
||||
assets: CompanyAsset[],
|
||||
initialId: number | null | undefined,
|
||||
companyId: number | null | undefined,
|
||||
user: DashboardUser,
|
||||
): AssetFieldHandle {
|
||||
let list = assets.filter((asset) => asset.kind === kind);
|
||||
let selected = list.find((asset) => asset.id === initialId) ?? null;
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "ui-field";
|
||||
const caption = document.createElement("label");
|
||||
caption.className = "ui-field__label";
|
||||
caption.textContent = label;
|
||||
const row = document.createElement("div");
|
||||
row.className = "b01-dashboard__asset-row";
|
||||
const preview = document.createElement("img");
|
||||
preview.className = "b01-dashboard__asset-preview";
|
||||
preview.alt = "";
|
||||
const name = document.createElement("span");
|
||||
name.className = "b01-dashboard__asset-name";
|
||||
|
||||
const render = (): void => {
|
||||
preview.hidden = !selected;
|
||||
if (selected) preview.src = companyAssetFileUrl(selected.id);
|
||||
name.textContent = selected ? selected.label : "(없음)";
|
||||
};
|
||||
render();
|
||||
|
||||
const pick = createButton({
|
||||
label: "선택…",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => {
|
||||
list = fresh;
|
||||
selected = next;
|
||||
render();
|
||||
}),
|
||||
});
|
||||
row.append(preview, name, pick);
|
||||
root.append(caption, row);
|
||||
return { root, value: () => selected?.id ?? null };
|
||||
}
|
||||
|
||||
function openAssetPickerModal(
|
||||
kind: CompanyAsset["kind"],
|
||||
assets: CompanyAsset[],
|
||||
selectedId: number | null,
|
||||
companyId: number | null | undefined,
|
||||
user: DashboardUser,
|
||||
onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void,
|
||||
): void {
|
||||
let list = assets;
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "b01-dashboard__modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b01-dashboard__modal-panel";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "b01-dashboard__modal-title";
|
||||
heading.textContent = `${KIND_LABEL[kind]} 선택`;
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__asset-grid";
|
||||
|
||||
const close = (): void => modal.remove();
|
||||
const choose = (asset: CompanyAsset | null): void => {
|
||||
onPick(asset, list);
|
||||
close();
|
||||
};
|
||||
|
||||
const card = (asset: CompanyAsset | null): HTMLElement => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "b01-dashboard__asset";
|
||||
if ((asset?.id ?? null) === selectedId) item.classList.add("is-selected");
|
||||
item.tabIndex = 0;
|
||||
item.addEventListener("click", () => choose(asset));
|
||||
item.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") choose(asset);
|
||||
});
|
||||
if (asset) {
|
||||
const image = document.createElement("img");
|
||||
image.src = companyAssetFileUrl(asset.id);
|
||||
image.alt = asset.label;
|
||||
const owner = asset.user_name ? asset.user_name : "회사 공용";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${asset.label} · ${owner}`;
|
||||
// 삭제는 두 번 눌러 확정 — 공용 confirm 모달은 이 모달 아래(z-index)에 깔려 못 쓴다.
|
||||
const remove = createButton({ label: "삭제", variant: "ghost" });
|
||||
remove.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
if (remove.textContent !== "정말 삭제?") {
|
||||
remove.textContent = "정말 삭제?";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteCompanyAsset(asset.id);
|
||||
list = list.filter((entry) => entry.id !== asset.id);
|
||||
if (asset.id === selectedId) onPick(null, list);
|
||||
item.remove();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "삭제 실패", "error");
|
||||
}
|
||||
});
|
||||
item.append(image, text, remove);
|
||||
} else {
|
||||
const text = document.createElement("span");
|
||||
text.textContent = "(없음)";
|
||||
item.append(text);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
grid.append(card(null), ...list.map(card));
|
||||
|
||||
// 신규 추가 — 이름 + 그림 파일 + (내 계정에 물릴지)
|
||||
const addTitle = document.createElement("h4");
|
||||
addTitle.className = "b01-dashboard__modal-subtitle";
|
||||
addTitle.textContent = "신규 추가";
|
||||
const label = createInputField({ label: "이름", placeholder: `예: ${KIND_LABEL[kind]} 2026` });
|
||||
const file = createInputField({ label: "그림 파일 (png·jpg·webp·svg, 2MB 이하)" });
|
||||
file.input.type = "file";
|
||||
file.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
||||
const mine = document.createElement("label");
|
||||
mine.className = "b01-dashboard__check";
|
||||
const mineBox = document.createElement("input");
|
||||
mineBox.type = "checkbox";
|
||||
mineBox.checked = kind === "SIGNATURE";
|
||||
mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`));
|
||||
const add = createButton({
|
||||
label: "올리고 선택",
|
||||
onClick: async () => {
|
||||
const chosen = file.input.files?.[0];
|
||||
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
||||
if (!chosen) return file.setError("그림 파일을 고르세요.");
|
||||
const form = new FormData();
|
||||
form.append("kind", kind);
|
||||
form.append("label", label.input.value.trim());
|
||||
form.append("file", chosen);
|
||||
if (mineBox.checked) form.append("user_id", String(user.id));
|
||||
if (companyId) form.append("company_id", String(companyId));
|
||||
try {
|
||||
const id = await createCompanyAsset(form);
|
||||
const fresh = await fetchCompanyAssets(companyId);
|
||||
list = fresh.filter((asset) => asset.kind === kind);
|
||||
choose(list.find((asset) => asset.id === id) ?? null);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "업로드 실패", "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b01-dashboard__actions";
|
||||
actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close }));
|
||||
panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions);
|
||||
modal.append(panel);
|
||||
document.body.append(modal);
|
||||
}
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
joinCompany,
|
||||
searchCompanies,
|
||||
addCompanyMember,
|
||||
fetchCompanyMembers,
|
||||
fetchCompanyAssets,
|
||||
type DashboardUser,
|
||||
type ProjectItem,
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -66,8 +69,16 @@ function openModal(title: string, body: HTMLElement[], onConfirm: () => Promise<
|
||||
document.body.append(modal);
|
||||
}
|
||||
|
||||
export function openEditProjectModal(user: DashboardUser, project: ProjectItem): void {
|
||||
export async function openEditProjectModal(
|
||||
user: DashboardUser,
|
||||
project: ProjectItem,
|
||||
): Promise<void> {
|
||||
const isUserOnly = user.role === "USER";
|
||||
// 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정).
|
||||
const [members, assets] = await Promise.all([
|
||||
fetchCompanyMembers(project.company_id),
|
||||
fetchCompanyAssets(project.company_id),
|
||||
]);
|
||||
|
||||
const name = createInputField({
|
||||
label: L("B01_Dashboard_Table_Project"),
|
||||
@@ -90,6 +101,55 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
|
||||
value: String(project.estimated_length_m ?? ""),
|
||||
});
|
||||
const memo = createInputField({ label: "비고", value: project.memo ?? "" });
|
||||
// 도면 표제란·표지에 그대로 실리는 값 — 프로그램이 지어낼 수 없어 여기서 받는다.
|
||||
const clientOrg = createInputField({
|
||||
label: "시행청 (도면 표제란)",
|
||||
value: project.client_org ?? "",
|
||||
});
|
||||
const projectNumber = createInputField({
|
||||
label: "연도·기번 (표지)",
|
||||
value: project.project_number ?? "",
|
||||
placeholder: "예: 2026년 간선임도(기번3-울진.대흥)",
|
||||
});
|
||||
const workAmount = createInputField({
|
||||
label: "사업량 (표지)",
|
||||
value: project.work_amount ?? "",
|
||||
placeholder: "예: L=2.14km",
|
||||
});
|
||||
// 설계일자는 확정일 자동이 아니라 사용자가 지정한다 (2026-09-02 사용자 확정).
|
||||
const designDate = createInputField({
|
||||
label: "설계일자 (도면 표제란)",
|
||||
type: "date",
|
||||
value: (project.design_date ?? "").slice(0, 10),
|
||||
});
|
||||
const personOptions = [
|
||||
{ value: "", text: "(미지정)" },
|
||||
...members.map((member) => ({
|
||||
value: String(member.id),
|
||||
text: member.position ? `${member.name} (${member.position})` : member.name,
|
||||
})),
|
||||
];
|
||||
const person = (label: string, current: number | null | undefined) =>
|
||||
createSelectField({ label, options: personOptions, value: String(current ?? "") });
|
||||
const pm = person("과업책임자 (도면 표제란)", project.pm_user_id);
|
||||
const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id);
|
||||
const designer = person("설계자 (도면 표제란)", project.designer_user_id);
|
||||
const logo = createAssetField(
|
||||
"회사 로고 (도면 표제란)",
|
||||
"LOGO",
|
||||
assets,
|
||||
project.logo_asset_id,
|
||||
project.company_id,
|
||||
user,
|
||||
);
|
||||
const signature = createAssetField(
|
||||
"설계자 서명 (도면 표제란)",
|
||||
"SIGNATURE",
|
||||
assets,
|
||||
project.signature_asset_id,
|
||||
project.company_id,
|
||||
user,
|
||||
);
|
||||
|
||||
if (isUserOnly) {
|
||||
name.input.disabled = true;
|
||||
@@ -98,24 +158,58 @@ export function openEditProjectModal(user: DashboardUser, project: ProjectItem):
|
||||
year.input.disabled = true;
|
||||
length.input.disabled = true;
|
||||
memo.input.disabled = true;
|
||||
clientOrg.input.disabled = true;
|
||||
projectNumber.input.disabled = true;
|
||||
workAmount.input.disabled = true;
|
||||
designDate.input.disabled = true;
|
||||
pm.select.disabled = true;
|
||||
fieldLead.select.disabled = true;
|
||||
designer.select.disabled = true;
|
||||
}
|
||||
|
||||
openModal(
|
||||
L("B01_Dashboard_EditProject"),
|
||||
[name.root, region.root, roadType.root, year.root, length.root, memo.root],
|
||||
async () => {
|
||||
await updateProject(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
region: region.input.value.trim() || null,
|
||||
road_type: roadType.input.value.trim() || null,
|
||||
project_year: year.input.value ? Number(year.input.value) : null,
|
||||
estimated_length_m: length.input.value ? Number(length.input.value) : null,
|
||||
memo: memo.input.value.trim() || null,
|
||||
status: project.status,
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
},
|
||||
// 칸이 많아 두 줄 격자로 — 첫 칸(공사명)만 가로로 다 쓴다.
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__form-grid";
|
||||
grid.append(
|
||||
name.root,
|
||||
region.root,
|
||||
roadType.root,
|
||||
year.root,
|
||||
length.root,
|
||||
memo.root,
|
||||
clientOrg.root,
|
||||
projectNumber.root,
|
||||
workAmount.root,
|
||||
designDate.root,
|
||||
pm.root,
|
||||
fieldLead.root,
|
||||
designer.root,
|
||||
logo.root,
|
||||
signature.root,
|
||||
);
|
||||
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
|
||||
|
||||
openModal(L("B01_Dashboard_EditProject"), [grid], async () => {
|
||||
await updateProject(project.id, {
|
||||
name: name.input.value.trim(),
|
||||
region: region.input.value.trim() || null,
|
||||
road_type: roadType.input.value.trim() || null,
|
||||
project_year: year.input.value ? Number(year.input.value) : null,
|
||||
estimated_length_m: length.input.value ? Number(length.input.value) : null,
|
||||
memo: memo.input.value.trim() || null,
|
||||
status: project.status,
|
||||
client_org: clientOrg.input.value.trim() || null,
|
||||
project_number: projectNumber.input.value.trim() || null,
|
||||
work_amount: workAmount.input.value.trim() || null,
|
||||
design_date: designDate.input.value || null,
|
||||
pm_user_id: userId(pm.select),
|
||||
field_lead_user_id: userId(fieldLead.select),
|
||||
designer_user_id: userId(designer.select),
|
||||
logo_asset_id: logo.value(),
|
||||
signature_asset_id: signature.value(),
|
||||
});
|
||||
showToast(L("B01_Dashboard_Saved"), "success");
|
||||
});
|
||||
}
|
||||
|
||||
export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem): void {
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
|
||||
.b01-dashboard__modal-panel {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2 * var(--spacing-24));
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -181,6 +183,70 @@
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b01-dashboard__modal-subtitle {
|
||||
margin: var(--spacing-24) 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 로고·서명 칸 — 프로젝트 수정 모달 안 미리보기 줄과 고르기 모달의 격자 */
|
||||
.b01-dashboard__asset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-preview {
|
||||
max-height: 36px;
|
||||
max-width: 96px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-name {
|
||||
flex: 1;
|
||||
font-size: var(--text-body-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b01-dashboard__asset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset.is-selected,
|
||||
.b01-dashboard__asset:focus-visible {
|
||||
border-color: var(--color-royal-amethyst);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__asset img {
|
||||
max-height: 64px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.b01-dashboard__check {
|
||||
display: block;
|
||||
margin: var(--spacing-8) 0 var(--spacing-16);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.b01-dashboard__header,
|
||||
.b01-dashboard__grid {
|
||||
|
||||
@@ -69,15 +69,12 @@ export async function uploadProjectFiles(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/files`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
@@ -92,22 +89,19 @@ export async function createUploadSession(
|
||||
completeUpload = false,
|
||||
lasFree = false,
|
||||
): Promise<ChunkSessionCreateResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
original_filename: file.name,
|
||||
size_bytes: file.size,
|
||||
chunk_size_bytes: chunkSizeBytes,
|
||||
fingerprint: fingerprint ?? null,
|
||||
complete_upload: completeUpload,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
|
||||
}
|
||||
|
||||
@@ -138,21 +132,18 @@ export async function finalizeUploadSession(
|
||||
fingerprint?: string | null,
|
||||
lasFree = false,
|
||||
): Promise<FileUploadResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/finalize`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
total_chunks: totalChunks,
|
||||
complete_upload: completeUpload,
|
||||
fingerprint: fingerprint ?? null,
|
||||
las_free: lasFree,
|
||||
}),
|
||||
});
|
||||
return await readJsonOrThrow<FileUploadResponse>(response);
|
||||
}
|
||||
|
||||
@@ -160,13 +151,10 @@ export async function fetchUploadStatus(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<UploadStatusResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return await readJsonOrThrow<UploadStatusResponse>(response);
|
||||
}
|
||||
|
||||
@@ -200,16 +188,11 @@ export interface UploadOverviewResponse {
|
||||
}
|
||||
|
||||
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
|
||||
export async function fetchUploadOverview(
|
||||
projectId: string,
|
||||
): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return await readJsonOrThrow<UploadOverviewResponse>(response);
|
||||
}
|
||||
|
||||
@@ -223,15 +206,10 @@ export interface WF1AnalysisStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkWF1AnalysisStatus(
|
||||
projectId: string,
|
||||
): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/status`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
return await readJsonOrThrow<WF1AnalysisStatus>(response);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
||||
import {
|
||||
createStepBar,
|
||||
WORKFLOW_STEP_ICONS,
|
||||
} from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
fetchUploadOverview,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
|
||||
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
|
||||
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
|
||||
@@ -129,10 +123,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return Array.from(slots.values()).filter((state) => state.file);
|
||||
}
|
||||
|
||||
function setCardState(
|
||||
slot: FileSlot,
|
||||
stateName: "empty" | "selected" | UploadStatus,
|
||||
): void {
|
||||
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
|
||||
const card = cardMap.get(slot);
|
||||
if (!card) return;
|
||||
card.classList.remove(
|
||||
@@ -146,9 +137,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
const cssState = stateName === "failed" ? "error" : stateName;
|
||||
card.classList.add(`b03-file__card--${cssState}`);
|
||||
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__card-badge-container",
|
||||
);
|
||||
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
|
||||
if (badgeContainer) {
|
||||
badgeContainer.replaceChildren();
|
||||
if (stateName === "empty") {
|
||||
@@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!state || !card) return;
|
||||
renderExtensionLabel(card, state);
|
||||
|
||||
const fileName = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-name",
|
||||
);
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__file-size",
|
||||
);
|
||||
const progress = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__progress-bar",
|
||||
);
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-bytes",
|
||||
);
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-speed",
|
||||
);
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(
|
||||
".b03-file__progress-eta",
|
||||
);
|
||||
const error = card.querySelector<HTMLDivElement>(
|
||||
".b03-file__error-message",
|
||||
);
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
);
|
||||
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
|
||||
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
|
||||
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
|
||||
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
|
||||
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
|
||||
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
|
||||
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
|
||||
|
||||
const percent = state.file
|
||||
? Math.min(100, (state.progressBytes / state.file.size) * 100)
|
||||
: 0;
|
||||
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
|
||||
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
|
||||
if (fileName)
|
||||
fileName.textContent =
|
||||
state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
|
||||
if (fileSize) {
|
||||
fileSize.textContent = state.file
|
||||
? formatBytes(state.file.size)
|
||||
@@ -271,13 +240,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (remove) remove.hidden = !state.file;
|
||||
|
||||
if (state.error) setCardState(slot, "failed");
|
||||
else if (!state.file)
|
||||
setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else
|
||||
setCardState(
|
||||
slot,
|
||||
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
|
||||
);
|
||||
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
|
||||
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
@@ -289,23 +253,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
renderSlot(slot);
|
||||
}
|
||||
|
||||
function validateFileForSlot(
|
||||
file: File,
|
||||
state: FileSlotState,
|
||||
): string | null {
|
||||
function validateFileForSlot(file: File, state: FileSlotState): string | null {
|
||||
const extension = getExtension(file.name);
|
||||
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
|
||||
if (!state.extensions.includes(extension))
|
||||
return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes)
|
||||
return L("B03_File_Error_Size");
|
||||
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
|
||||
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assignFileToSlot(
|
||||
file: File,
|
||||
targetSlot?: FileSlot,
|
||||
): Promise<void> {
|
||||
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
|
||||
const state = targetSlot ? slots.get(targetSlot) : undefined;
|
||||
if (!state) {
|
||||
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
|
||||
@@ -317,19 +273,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (!targetSlot && state.file && state.file.name !== file.name) {
|
||||
showErrorMessage(
|
||||
state.slot,
|
||||
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
|
||||
);
|
||||
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
|
||||
return;
|
||||
}
|
||||
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
|
||||
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
|
||||
if (state.serverUploaded) {
|
||||
const accepted = await confirmReplaceUpload(
|
||||
L(state.labelKey),
|
||||
state.serverUploaded.name,
|
||||
);
|
||||
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
|
||||
if (!accepted) return;
|
||||
}
|
||||
|
||||
@@ -350,18 +300,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
updateUploadButton();
|
||||
}
|
||||
|
||||
function onFileSelected(
|
||||
selection: readonly File[],
|
||||
targetSlot?: FileSlot,
|
||||
): void {
|
||||
function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void {
|
||||
if (selection.length === 0) return;
|
||||
// LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) —
|
||||
// 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다.
|
||||
const pointCloudExtensions = slots.get("las_laz")?.extensions ?? [];
|
||||
const files = lasFreeDesign
|
||||
? selection.filter(
|
||||
(file) => !pointCloudExtensions.includes(getExtension(file.name)),
|
||||
)
|
||||
? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name)))
|
||||
: selection;
|
||||
const blocked = files.length !== selection.length;
|
||||
if (blocked && files.length === 0) {
|
||||
@@ -433,8 +378,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
|
||||
*/
|
||||
function isSlotRequired(state: FileSlotState): boolean {
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
|
||||
return routeIsShapefile();
|
||||
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile();
|
||||
if (state.slot === "las_laz") return !lasFreeDesign;
|
||||
return state.isRequired;
|
||||
}
|
||||
@@ -447,14 +391,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
|
||||
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
|
||||
const missingRequired = Array.from(slots.values()).some(
|
||||
(state) =>
|
||||
isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
(state) => isSlotRequired(state) && !state.file && !state.serverUploaded,
|
||||
);
|
||||
if (missingRequired) return L("B03_File_Error_RequiredSlots");
|
||||
if (!lasFreeDesign) {
|
||||
const lasState = slots.get("las_laz");
|
||||
if (!lasState?.file && !lasState?.serverUploaded)
|
||||
return L("B03_File_Error_Las");
|
||||
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
|
||||
}
|
||||
for (const state of selected) {
|
||||
if (state.error) return state.error;
|
||||
@@ -490,8 +432,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
: "prj"
|
||||
: Array.from(slots.values()).find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
)?.slot;
|
||||
const state = slot ? slots.get(slot) : undefined;
|
||||
if (state) {
|
||||
@@ -533,26 +474,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!card) throw new Error("file-card-template is invalid");
|
||||
card.dataset.slotId = state.slot;
|
||||
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(
|
||||
state.labelKey,
|
||||
);
|
||||
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
|
||||
renderExtensionLabel(card, state);
|
||||
const input = card.querySelector<HTMLInputElement>(
|
||||
".b03-file__slot-input",
|
||||
)!;
|
||||
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
|
||||
input.accept = state.extensions.join(",");
|
||||
const select = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-select",
|
||||
)!;
|
||||
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
|
||||
select.textContent = L("B03_File_Card_Select");
|
||||
select.addEventListener("click", () => input.click());
|
||||
input.addEventListener("change", () => {
|
||||
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
|
||||
input.value = "";
|
||||
});
|
||||
const remove = card.querySelector<HTMLButtonElement>(
|
||||
".b03-file__card-remove",
|
||||
)!;
|
||||
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
|
||||
remove.textContent = "×";
|
||||
remove.title = L("B03_File_Card_Remove");
|
||||
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
|
||||
@@ -599,9 +532,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
for (const state of selectedStates()) {
|
||||
const stored = localStorage.getItem(
|
||||
makeSessionKey(activeProjectId, state.file!),
|
||||
);
|
||||
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
|
||||
if (!stored) continue;
|
||||
const session = JSON.parse(stored) as StoredUploadSession;
|
||||
state.uploadSessionId = session.uploadSessionId;
|
||||
@@ -671,8 +602,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
|
||||
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
|
||||
showToast(L("B03_Temp_Attach_Failed"), "error");
|
||||
}
|
||||
@@ -701,9 +631,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startChunkedUpload(
|
||||
targetStates = selectedStates(),
|
||||
): Promise<void> {
|
||||
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
|
||||
if (isUploading) return;
|
||||
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
|
||||
if (tempPicker.selected()) {
|
||||
@@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B03_File_Analysis_StillRunning"), "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const failed = targetStates.find(
|
||||
(state) => state.uploadStatus === "uploading",
|
||||
);
|
||||
const detail =
|
||||
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
|
||||
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
|
||||
if (failed) showErrorMessage(failed.slot, detail);
|
||||
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
|
||||
showToast(L("B03_File_Upload_Failed"), "error");
|
||||
@@ -789,9 +714,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
function onB03_File_Drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dropzone.classList.remove("is-dragging");
|
||||
onFileSelected(
|
||||
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
|
||||
);
|
||||
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", onB03_File_Select_Change);
|
||||
@@ -803,9 +726,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("is-dragging");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () =>
|
||||
dropzone.classList.remove("is-dragging"),
|
||||
);
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
|
||||
dropzone.addEventListener("drop", onB03_File_Drop);
|
||||
|
||||
uploadButton = createButton({
|
||||
@@ -872,10 +793,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
lasFreeCheck.addEventListener("change", () => {
|
||||
lasFreeDesign = lasFreeCheck.checked;
|
||||
if (activeProjectId) {
|
||||
localStorage.setItem(
|
||||
`b03_las_free_${activeProjectId}`,
|
||||
lasFreeDesign ? "1" : "0",
|
||||
);
|
||||
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
|
||||
}
|
||||
applyLasFreeState();
|
||||
pageError.textContent = "";
|
||||
@@ -885,13 +803,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
|
||||
terrainGroup.append(lasFreeRow);
|
||||
|
||||
const routePanel = document.createElement("div");
|
||||
routePanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
routePanel.append(routeGroup);
|
||||
|
||||
const terrainPanel = document.createElement("div");
|
||||
terrainPanel.className =
|
||||
"b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
|
||||
terrainPanel.append(terrainGroup);
|
||||
|
||||
const cardsContainer = document.createElement("div");
|
||||
|
||||
@@ -295,9 +295,7 @@
|
||||
color: var(--color-royal-amethyst, #3e0079);
|
||||
background: var(--color-mist-violet, #edecff);
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
margin-right: var(
|
||||
--spacing-8
|
||||
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
|
||||
}
|
||||
|
||||
.b03-file__card-heading {
|
||||
@@ -348,9 +346,7 @@
|
||||
font-size: var(--text-body-sm, 14px);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin-left: var(
|
||||
--spacing-8
|
||||
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
|
||||
transition: all var(--transition-base, 0.2s);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,40 +6,16 @@ import { ui_locales } from "@ui/ui_template_locale";
|
||||
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
|
||||
*/
|
||||
export type FileSlot =
|
||||
| "csv"
|
||||
| "shx"
|
||||
| "dbf"
|
||||
| "cpg"
|
||||
| "route_prj"
|
||||
| "las_laz"
|
||||
| "prj"
|
||||
| "tfw"
|
||||
| "tif"
|
||||
| "dxf";
|
||||
"csv" | "shx" | "dbf" | "cpg" | "route_prj" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
|
||||
|
||||
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = [
|
||||
"csv",
|
||||
"shx",
|
||||
"dbf",
|
||||
"cpg",
|
||||
"route_prj",
|
||||
];
|
||||
export const ROUTE_SLOTS: readonly FileSlot[] = ["csv", "shx", "dbf", "cpg", "route_prj"];
|
||||
|
||||
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = [
|
||||
"las_laz",
|
||||
"prj",
|
||||
"tfw",
|
||||
"tif",
|
||||
];
|
||||
export const TERRAIN_SLOTS: readonly FileSlot[] = ["las_laz", "prj", "tfw", "tif"];
|
||||
|
||||
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
|
||||
"shx",
|
||||
"dbf",
|
||||
"route_prj",
|
||||
];
|
||||
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = ["shx", "dbf", "route_prj"];
|
||||
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
|
||||
|
||||
export interface SlotConfig {
|
||||
@@ -179,9 +155,7 @@ export function planSlotAssignments(
|
||||
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
|
||||
}
|
||||
const config = slotConfigs.find(
|
||||
(candidate) =>
|
||||
candidate.slot !== "route_prj" &&
|
||||
candidate.extensions.includes(extension),
|
||||
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
|
||||
);
|
||||
return { file, slot: config?.slot };
|
||||
});
|
||||
|
||||
@@ -6,14 +6,8 @@
|
||||
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
PROGRESS_UPDATE_INTERVAL_MS,
|
||||
UPLOAD_CHUNK_SIZE_MB,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
|
||||
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
@@ -24,10 +18,7 @@ import {
|
||||
uploadFileChunk,
|
||||
type UploadedFileResult,
|
||||
} from "./B03_FileInput_Api_Fetch";
|
||||
import {
|
||||
saveB03UploadedFile,
|
||||
updateB03AnalysisState,
|
||||
} from "./B03_FileInput_State";
|
||||
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
|
||||
import {
|
||||
makeSessionKey,
|
||||
type FileSlotState,
|
||||
@@ -42,10 +33,7 @@ function L(key: keyof typeof ui_locales): string {
|
||||
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
|
||||
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
|
||||
*/
|
||||
export function confirmReplaceUpload(
|
||||
slotLabel: string,
|
||||
fileName: string,
|
||||
): Promise<boolean> {
|
||||
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "b03-file__modal-backdrop";
|
||||
@@ -120,9 +108,7 @@ export async function uploadOneFile(
|
||||
|
||||
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
|
||||
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
|
||||
const fingerprint = state.uploadSessionId
|
||||
? null
|
||||
: await fileFingerprint(file);
|
||||
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
|
||||
let session = state.uploadSessionId;
|
||||
if (!session) {
|
||||
const created = await createUploadSession(
|
||||
@@ -156,22 +142,11 @@ export async function uploadOneFile(
|
||||
const start = chunkIndex * chunkSizeBytes;
|
||||
const end = Math.min(file.size, start + chunkSizeBytes);
|
||||
const chunkStartedAt = performance.now();
|
||||
await uploadFileChunk(
|
||||
projectId,
|
||||
session,
|
||||
chunkIndex,
|
||||
file.slice(start, end),
|
||||
);
|
||||
const elapsedSec = Math.max(
|
||||
0.001,
|
||||
(performance.now() - chunkStartedAt) / 1000,
|
||||
);
|
||||
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
|
||||
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
|
||||
state.progressBytes = end;
|
||||
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
|
||||
state.etaSeconds =
|
||||
state.speedMbs > 0
|
||||
? (file.size - end) / 1024 / 1024 / state.speedMbs
|
||||
: null;
|
||||
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
|
||||
|
||||
const stored: StoredUploadSession = {
|
||||
key: storageKey,
|
||||
@@ -188,10 +163,7 @@ export async function uploadOneFile(
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const now = performance.now();
|
||||
if (
|
||||
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
|
||||
chunkIndex === totalChunks - 1
|
||||
) {
|
||||
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
|
||||
lastPaintAt = now;
|
||||
onProgress();
|
||||
}
|
||||
@@ -213,10 +185,7 @@ export async function uploadOneFile(
|
||||
});
|
||||
state.progressBytes = file.size;
|
||||
state.speedMbs =
|
||||
file.size /
|
||||
1024 /
|
||||
1024 /
|
||||
Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
|
||||
state.etaSeconds = 0;
|
||||
state.uploadStatus = "completed";
|
||||
onProgress();
|
||||
@@ -237,12 +206,9 @@ export async function uploadOneFile(
|
||||
*
|
||||
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
|
||||
*/
|
||||
export function isInitialPipelineRunning(
|
||||
state: WorkflowState | undefined,
|
||||
): boolean {
|
||||
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
|
||||
if (!state?.stages?.length) return false;
|
||||
const stageAt = (stageNo: number) =>
|
||||
state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
|
||||
const fileInput = stageAt(0);
|
||||
const preprocess = stageAt(1);
|
||||
const section = stageAt(3);
|
||||
|
||||
@@ -8,10 +8,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
|
||||
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
|
||||
import type {
|
||||
SurfaceBounds,
|
||||
SurfaceModelSummary,
|
||||
} from "./B04_PreProcess_Api_Fetch";
|
||||
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
bindCursorPivotControls,
|
||||
bindSurfaceViewerTheme,
|
||||
@@ -43,11 +40,7 @@ export interface SurfaceTerrainViewer {
|
||||
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
|
||||
setSelection: (sourceFilter: string, method: string) => void;
|
||||
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
|
||||
showOverlay: (
|
||||
sourceFilter: string,
|
||||
method: string,
|
||||
smooth: boolean,
|
||||
) => Promise<boolean>;
|
||||
showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise<boolean>;
|
||||
applyCameraState: (state: SurfaceCameraState) => void;
|
||||
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
|
||||
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
|
||||
@@ -242,12 +235,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
scene.background = new THREE.Color(color);
|
||||
});
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
SURFACE_CAMERA_FOV,
|
||||
1,
|
||||
0.01,
|
||||
100000,
|
||||
);
|
||||
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
|
||||
@@ -291,8 +279,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
|
||||
function disposeObject(obj: THREE.Object3D) {
|
||||
obj.traverse((child) => {
|
||||
const renderable = child as
|
||||
THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||
renderable.geometry?.dispose();
|
||||
const material = renderable.material;
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
||||
@@ -414,9 +401,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(routeLineColor()),
|
||||
});
|
||||
routeGroup.add(
|
||||
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
|
||||
);
|
||||
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
|
||||
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
|
||||
if (terrainMesh) fitCamera(terrainMesh);
|
||||
}
|
||||
@@ -432,13 +417,36 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
return { center, span };
|
||||
};
|
||||
|
||||
/** 화면맞춤 기준 범위 — 지표면 범위에 계획노선까지 담는다.
|
||||
*
|
||||
* 카메라 타깃은 늘 지표면 중심(0,0,0)이라 범위를 한쪽으로만 늘려서는 소용이 없다.
|
||||
* 중심에서 가장 먼 노선 정점까지를 반폭으로 잡아 **대칭으로** 넓힌다. 그러지 않으면
|
||||
* 라이다가 노선의 일부만 덮을 때 나머지가 화면 밖으로 잘린다(2026-09-02 용화 실측:
|
||||
* 노선 2,136m 중 라이다는 1,400m만 덮어 오른쪽이 캔버스 밖으로 나갔다). */
|
||||
const fitBounds = (): SurfaceBounds | null => {
|
||||
if (!referenceBounds || routePoints.length < 2) return referenceBounds;
|
||||
const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2;
|
||||
const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2;
|
||||
let halfX = (referenceBounds.x_max - referenceBounds.x_min) / 2;
|
||||
let halfY = (referenceBounds.y_max - referenceBounds.y_min) / 2;
|
||||
for (const point of routePoints) {
|
||||
halfX = Math.max(halfX, Math.abs(point.x - cx));
|
||||
halfY = Math.max(halfY, Math.abs(point.y - cy));
|
||||
}
|
||||
return {
|
||||
...referenceBounds,
|
||||
x_min: cx - halfX,
|
||||
x_max: cx + halfX,
|
||||
y_min: cy - halfY,
|
||||
y_max: cy + halfY,
|
||||
};
|
||||
};
|
||||
|
||||
const fitCamera = (object: THREE.Object3D) => {
|
||||
const { span } = getFitParams(object);
|
||||
const aspect =
|
||||
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const distance = referenceBounds
|
||||
? getTopFitDistance(referenceBounds, aspect)
|
||||
: span * 1.2;
|
||||
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
|
||||
const bounds = fitBounds();
|
||||
const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2;
|
||||
controls.target.set(0, 0, 0);
|
||||
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
|
||||
camera.position.set(0, distance, distance * TOP_VIEW_TILT);
|
||||
@@ -500,15 +508,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
|
||||
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
||||
const match = currentModelsList.find((m) => {
|
||||
const typeMatches =
|
||||
m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||
const configuredFilter = m.generation_params?.source_filter;
|
||||
const filterMatches =
|
||||
(typeof configuredFilter === "string" &&
|
||||
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
|
||||
Boolean(
|
||||
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
|
||||
);
|
||||
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
|
||||
return typeMatches && filterMatches;
|
||||
});
|
||||
|
||||
@@ -519,8 +524,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
}
|
||||
|
||||
const modelId = match.id;
|
||||
const isSmooth =
|
||||
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
||||
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
|
||||
currentModelId = modelId;
|
||||
currentModelSmooth = isSmooth;
|
||||
const generation = ++loadGeneration;
|
||||
@@ -567,8 +571,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
gltf.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.material.side = THREE.DoubleSide;
|
||||
child.material.vertexColors =
|
||||
child.geometry.hasAttribute("color");
|
||||
child.material.vertexColors = child.geometry.hasAttribute("color");
|
||||
}
|
||||
});
|
||||
gltf.scene.visible = surfCheck.checked;
|
||||
@@ -581,8 +584,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
},
|
||||
() => {
|
||||
if (generation !== loadGeneration) return;
|
||||
statusSpan.textContent =
|
||||
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||
showProgress(null, null);
|
||||
},
|
||||
);
|
||||
@@ -793,26 +795,18 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
if (terrainMesh && terrainMesh.visible) {
|
||||
scaleBar.hidden = false;
|
||||
const dist = camera.position.distanceTo(controls.target);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(
|
||||
dist,
|
||||
viewerArea.clientHeight,
|
||||
);
|
||||
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
|
||||
const roughMeters = 100 * metersPerPixel;
|
||||
const prettyMeters = niceScaleDistance(roughMeters);
|
||||
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
||||
scaleLabel.textContent =
|
||||
prettyMeters >= 1000
|
||||
? `${(prettyMeters / 1000).toFixed(0)} km`
|
||||
: `${prettyMeters} m`;
|
||||
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||
} else {
|
||||
scaleBar.hidden = true;
|
||||
}
|
||||
|
||||
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
|
||||
if (
|
||||
labelsDirty ||
|
||||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
|
||||
) {
|
||||
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
|
||||
labelsDirty = false;
|
||||
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
|
||||
labelElements.forEach((label) => {
|
||||
@@ -854,8 +848,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
intervalForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const interval = Number(intervalInput.value);
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
|
||||
return;
|
||||
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
|
||||
intervalSubmit.disabled = true;
|
||||
await loadSelectedContours(currentModelId, currentModelSmooth, true);
|
||||
intervalSubmit.disabled = false;
|
||||
@@ -904,13 +897,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
clearOverlay();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return loadOverlay(
|
||||
currentProjectId,
|
||||
currentModelsList,
|
||||
sourceFilter,
|
||||
method,
|
||||
smooth,
|
||||
);
|
||||
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
|
||||
},
|
||||
applyCameraState,
|
||||
onCameraChange(listener) {
|
||||
@@ -927,8 +914,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||
syncSmoothingSupport();
|
||||
},
|
||||
setContourInterval(interval) {
|
||||
if (Number.isFinite(interval) && interval > 0)
|
||||
intervalInput.value = String(interval);
|
||||
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
|
||||
},
|
||||
getContourInterval() {
|
||||
return Number.parseFloat(intervalInput.value);
|
||||
|
||||
@@ -44,6 +44,10 @@ from B05_Profile.B05_Profile_Engine_Grade_Solver import (
|
||||
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
|
||||
|
||||
ALIGNMENT_PROFILE_ID = "design_grade_line"
|
||||
# 계획선이 어느 진입점에서 나왔는지 저장본만 보고 가릴 수 있게 값을 나눈다. 1차·2차가
|
||||
# 같은 `_profile_entry()` 를 쓰다 보니 둘 다 `station_alignment` 로 나가, 사고 조사 때
|
||||
# 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02).
|
||||
PIPE_ANCHORED_BASIS = "pipe_anchored"
|
||||
ALIGNMENT_BASIS = "station_alignment"
|
||||
|
||||
|
||||
@@ -68,6 +72,7 @@ def _profile_entry(
|
||||
direction: str,
|
||||
balanced: bool,
|
||||
warnings: list[str],
|
||||
basis: str = ALIGNMENT_BASIS,
|
||||
) -> dict[str, Any]:
|
||||
"""`design_profiles` 배열 계약(기존 스키마)에 맞춰 계획선 한 벌을 만든다.
|
||||
|
||||
@@ -80,7 +85,7 @@ def _profile_entry(
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
"id": ALIGNMENT_PROFILE_ID,
|
||||
"name": "계획선",
|
||||
"basis": ALIGNMENT_BASIS,
|
||||
"basis": basis,
|
||||
"criteria": {**options.as_dict(), "resolved_main_direction": direction},
|
||||
# 구 스키마 호환: 변화점 목록을 pvis 이름으로도 노출한다.
|
||||
"pvis": alignment["pvi"],
|
||||
@@ -254,7 +259,9 @@ def design_pipe_anchored_profile(
|
||||
)
|
||||
warnings.extend(alignment["warnings"])
|
||||
balanced = bool(alignment["balance"]["within_tolerance"])
|
||||
return alignment, _profile_entry(alignment, options, direction, balanced, warnings)
|
||||
return alignment, _profile_entry(
|
||||
alignment, options, direction, balanced, warnings, PIPE_ANCHORED_BASIS
|
||||
)
|
||||
|
||||
|
||||
def design_alignment_profile(
|
||||
@@ -389,4 +396,6 @@ def rebuild_alignment_profile(
|
||||
direction,
|
||||
alignment["balance"]["within_tolerance"],
|
||||
alignment["warnings"],
|
||||
# 재구성은 저장된 자동 선형을 그대로 쓰므로 출처도 그대로 물려받는다.
|
||||
str(previous.get("basis") or ALIGNMENT_BASIS),
|
||||
)
|
||||
|
||||
@@ -19,6 +19,15 @@ from B05_Profile.B05_Profile_Engine_Geometry import (
|
||||
point_to_polyline_dist_2d,
|
||||
resample_polyline_2d,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RidgeValley_Graph import (
|
||||
_build_barrier_mask,
|
||||
_build_edges,
|
||||
_collect_nodes,
|
||||
_endpoint_connectors,
|
||||
_Grid,
|
||||
_segment_feasible,
|
||||
_turn_angle,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Skeleton import load_or_build_skeleton
|
||||
from B05_Profile.B05_Profile_Engine_Solver import (
|
||||
_MODELS_SUBDIR,
|
||||
@@ -34,255 +43,11 @@ from config.config_system import (
|
||||
SKELETON_NODE_SPACING_M,
|
||||
)
|
||||
|
||||
# 엣지 후보 탐색 파라미터 (알고리즘 내부 상수)
|
||||
MAX_EDGE_LEN_M = 400.0
|
||||
MIN_EDGE_LEN_M = 20.0
|
||||
MAX_NEIGHBORS_PER_NODE = 16
|
||||
# 교각 페널티 (알고리즘 내부 상수) — 엣지 후보 상수는 _Graph 로 옮겼다(2026-09-02).
|
||||
TURN_PENALTY_W = 60.0
|
||||
MAX_TURN_DEG = 120.0
|
||||
|
||||
|
||||
class _Grid:
|
||||
"""비용면 격자에 대한 표고/유효성 조회 헬퍼."""
|
||||
|
||||
def __init__(self, x, y, z, valid, grid_res):
|
||||
self.x = np.asarray(x, dtype=np.float64)
|
||||
self.y = np.asarray(y, dtype=np.float64)
|
||||
self.z = np.asarray(z, dtype=np.float64)
|
||||
self.valid = np.asarray(valid, dtype=bool)
|
||||
self.res = float(grid_res)
|
||||
|
||||
def _idx(self, coords: np.ndarray, v: float) -> int:
|
||||
i = int(np.clip(np.searchsorted(coords, v), 0, len(coords) - 1))
|
||||
j = max(i - 1, 0)
|
||||
return j if abs(v - coords[j]) <= abs(coords[i] - v) else i
|
||||
|
||||
def rc(self, px: float, py: float) -> tuple[int, int]:
|
||||
return self._idx(self.y, py), self._idx(self.x, px)
|
||||
|
||||
def z_at(self, px: float, py: float) -> float:
|
||||
r, c = self.rc(px, py)
|
||||
return float(self.z[r, c])
|
||||
|
||||
def valid_at(self, px: float, py: float) -> bool:
|
||||
in_bounds = (self.x[0] <= px <= self.x[-1]) and (self.y[0] <= py <= self.y[-1])
|
||||
if not in_bounds:
|
||||
return False
|
||||
r, c = self.rc(px, py)
|
||||
return bool(self.valid[r, c])
|
||||
|
||||
|
||||
def _collect_nodes(
|
||||
skeleton: dict[str, Any], spacing_m: float
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""능선/계곡 polyline 정점을 spacing 간격으로 다운샘플해 노드 배열을 만든다."""
|
||||
pos, kind, on_main = [], [], []
|
||||
|
||||
def _add(polys, k, is_main):
|
||||
for item in polys:
|
||||
pl = item["polyline"]
|
||||
acc = spacing_m
|
||||
prev = None
|
||||
for p in pl:
|
||||
step = spacing_m if prev is None else math.hypot(p[0] - prev[0], p[1] - prev[1])
|
||||
acc += step
|
||||
prev = p
|
||||
if acc >= spacing_m:
|
||||
acc = 0.0
|
||||
pos.append([p[0], p[1], p[2]])
|
||||
kind.append(k)
|
||||
on_main.append(is_main)
|
||||
|
||||
_add(skeleton.get("minor_ridge", []), 0, False)
|
||||
_add(skeleton.get("minor_valley", []), 1, False)
|
||||
_add(skeleton.get("main_ridge", []), 0, True)
|
||||
_add(skeleton.get("main_valley", []), 1, True)
|
||||
|
||||
if not pos:
|
||||
return (np.zeros((0, 3)), np.zeros(0, dtype=np.int8), np.zeros(0, dtype=bool))
|
||||
return (
|
||||
np.asarray(pos, dtype=np.float64),
|
||||
np.asarray(kind, dtype=np.int8),
|
||||
np.asarray(on_main, dtype=bool),
|
||||
)
|
||||
|
||||
|
||||
def _build_barrier_mask(grid: _Grid, skeleton: dict[str, Any]) -> np.ndarray:
|
||||
"""주능선/주계곡 셀을 True로 표시한 구획 경계 마스크."""
|
||||
barrier = np.zeros(grid.z.shape, dtype=bool)
|
||||
for key in ("main_ridge", "main_valley"):
|
||||
for item in skeleton.get(key, []):
|
||||
for p in item["polyline"]:
|
||||
r, c = grid.rc(p[0], p[1])
|
||||
barrier[r, c] = True
|
||||
return barrier
|
||||
|
||||
|
||||
def _segment_feasible(
|
||||
a: np.ndarray,
|
||||
b: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
enforce_grade_window: bool = True,
|
||||
) -> bool:
|
||||
"""a→b 직선이 정속경사 세그먼트로 성립하는지 검사한다."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return False
|
||||
design_grade = (b[2] - a[2]) / length
|
||||
g = abs(design_grade)
|
||||
if enforce_grade_window:
|
||||
if not (min_grade <= g <= max_grade):
|
||||
return False
|
||||
elif g > max_grade:
|
||||
return False
|
||||
|
||||
step = max(grid.res, 1.0)
|
||||
n_steps = max(int(length / step), 1)
|
||||
for i in range(n_steps + 1):
|
||||
t = i / n_steps
|
||||
px, py = a[0] + t * dx, a[1] + t * dy
|
||||
if not grid.valid_at(px, py):
|
||||
return False
|
||||
s = t * length
|
||||
z_design = a[2] + design_grade * s
|
||||
z_terrain = grid.z_at(px, py)
|
||||
allowed = tol * max(s, length - s) + grid.res
|
||||
if abs(z_terrain - z_design) > allowed:
|
||||
return False
|
||||
if min(s, length - s) > endpoint_free_m:
|
||||
r, c = grid.rc(px, py)
|
||||
if barrier[r, c]:
|
||||
return False
|
||||
for circ in blocked_circles:
|
||||
if math.hypot(px - circ["x"], py - circ["y"]) < circ["radius_m"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_edges(
|
||||
pos: np.ndarray,
|
||||
kind: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> dict[int, list[tuple[int, float]]]:
|
||||
"""능선↔계곡 노드 쌍의 정속경사 직선 엣지를 만든다 (무방향, 길이 저장)."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(len(pos))}
|
||||
if len(pos) == 0:
|
||||
return adj
|
||||
|
||||
ridge_idx = np.nonzero(kind == 0)[0]
|
||||
valley_idx = np.nonzero(kind == 1)[0]
|
||||
if len(ridge_idx) == 0 or len(valley_idx) == 0:
|
||||
return adj
|
||||
|
||||
valley_tree = cKDTree(pos[valley_idx, :2])
|
||||
for ri in ridge_idx:
|
||||
cand = valley_tree.query_ball_point(pos[ri, :2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(
|
||||
cand,
|
||||
key=lambda j: (
|
||||
(pos[ri, 0] - pos[valley_idx[j], 0]) ** 2
|
||||
+ (pos[ri, 1] - pos[valley_idx[j], 1]) ** 2
|
||||
),
|
||||
)
|
||||
added = 0
|
||||
for j in cand:
|
||||
vi = int(valley_idx[j])
|
||||
length = math.hypot(pos[ri, 0] - pos[vi, 0], pos[ri, 1] - pos[vi, 1])
|
||||
if length < MIN_EDGE_LEN_M:
|
||||
continue
|
||||
if pos[ri, 2] <= pos[vi, 2]:
|
||||
continue
|
||||
if not _segment_feasible(
|
||||
pos[ri],
|
||||
pos[vi],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
min_grade,
|
||||
max_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
):
|
||||
continue
|
||||
adj[int(ri)].append((vi, length))
|
||||
adj[vi].append((int(ri), length))
|
||||
added += 1
|
||||
if added >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return adj
|
||||
|
||||
|
||||
def _endpoint_connectors(
|
||||
pt: dict[str, float],
|
||||
pos: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
max_uphill_grade: float,
|
||||
max_downhill_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""BP/CP/EP를 그래프 노드에 잇는 연결 세그먼트 후보."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
if len(pos) == 0:
|
||||
return []
|
||||
p = np.array([pt["x"], pt["y"], grid.z_at(pt["x"], pt["y"])])
|
||||
tree = cKDTree(pos[:, :2])
|
||||
cand = tree.query_ball_point(p[:2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(cand, key=lambda j: (p[0] - pos[j, 0]) ** 2 + (p[1] - pos[j, 1]) ** 2)
|
||||
out = []
|
||||
for j in cand:
|
||||
length = math.hypot(p[0] - pos[j, 0], p[1] - pos[j, 1])
|
||||
if length < 1e-6:
|
||||
out.append((int(j), max(length, 0.01)))
|
||||
continue
|
||||
applicable = max_uphill_grade if pos[j, 2] > p[2] else max_downhill_grade
|
||||
if _segment_feasible(
|
||||
p,
|
||||
pos[j],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
0.0,
|
||||
applicable,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
enforce_grade_window=False,
|
||||
):
|
||||
out.append((int(j), length))
|
||||
if len(out) >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _turn_angle(p_prev, p_curr, p_next) -> float:
|
||||
"""진행방향 변화(교각) [rad]. 0 = 직진."""
|
||||
v1 = (p_curr[0] - p_prev[0], p_curr[1] - p_prev[1])
|
||||
v2 = (p_next[0] - p_curr[0], p_next[1] - p_curr[1])
|
||||
n1, n2 = math.hypot(*v1), math.hypot(*v2)
|
||||
if n1 < 1e-9 or n2 < 1e-9:
|
||||
return 0.0
|
||||
cosang = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
|
||||
return math.acos(cosang)
|
||||
|
||||
|
||||
def _search_segment(
|
||||
start_pt: dict[str, float],
|
||||
end_pt: dict[str, float],
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""B05 능선-계곡 길찾기 — 그래프 뼈대(격자 조회·노드·엣지).
|
||||
|
||||
`B05_Profile_Engine_RidgeValley` 에서 떼어낸 앞단이다(700줄 제한, 2026-09-02).
|
||||
탐색·선형(fillet)·진입점 계산은 원래 파일에 남고, 여기에는 **비용면 격자 조회와
|
||||
정속경사 세그먼트 판정·엣지 생성**만 둔다. 호출부는 원래 파일뿐이다.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 엣지 후보 탐색 파라미터 (알고리즘 내부 상수)
|
||||
MAX_EDGE_LEN_M = 400.0
|
||||
MIN_EDGE_LEN_M = 20.0
|
||||
MAX_NEIGHBORS_PER_NODE = 16
|
||||
|
||||
|
||||
class _Grid:
|
||||
"""비용면 격자에 대한 표고/유효성 조회 헬퍼."""
|
||||
|
||||
def __init__(self, x, y, z, valid, grid_res):
|
||||
self.x = np.asarray(x, dtype=np.float64)
|
||||
self.y = np.asarray(y, dtype=np.float64)
|
||||
self.z = np.asarray(z, dtype=np.float64)
|
||||
self.valid = np.asarray(valid, dtype=bool)
|
||||
self.res = float(grid_res)
|
||||
|
||||
def _idx(self, coords: np.ndarray, v: float) -> int:
|
||||
i = int(np.clip(np.searchsorted(coords, v), 0, len(coords) - 1))
|
||||
j = max(i - 1, 0)
|
||||
return j if abs(v - coords[j]) <= abs(coords[i] - v) else i
|
||||
|
||||
def rc(self, px: float, py: float) -> tuple[int, int]:
|
||||
return self._idx(self.y, py), self._idx(self.x, px)
|
||||
|
||||
def z_at(self, px: float, py: float) -> float:
|
||||
r, c = self.rc(px, py)
|
||||
return float(self.z[r, c])
|
||||
|
||||
def valid_at(self, px: float, py: float) -> bool:
|
||||
in_bounds = (self.x[0] <= px <= self.x[-1]) and (self.y[0] <= py <= self.y[-1])
|
||||
if not in_bounds:
|
||||
return False
|
||||
r, c = self.rc(px, py)
|
||||
return bool(self.valid[r, c])
|
||||
|
||||
|
||||
def _collect_nodes(
|
||||
skeleton: dict[str, Any], spacing_m: float
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""능선/계곡 polyline 정점을 spacing 간격으로 다운샘플해 노드 배열을 만든다."""
|
||||
pos, kind, on_main = [], [], []
|
||||
|
||||
def _add(polys, k, is_main):
|
||||
for item in polys:
|
||||
pl = item["polyline"]
|
||||
acc = spacing_m
|
||||
prev = None
|
||||
for p in pl:
|
||||
step = spacing_m if prev is None else math.hypot(p[0] - prev[0], p[1] - prev[1])
|
||||
acc += step
|
||||
prev = p
|
||||
if acc >= spacing_m:
|
||||
acc = 0.0
|
||||
pos.append([p[0], p[1], p[2]])
|
||||
kind.append(k)
|
||||
on_main.append(is_main)
|
||||
|
||||
_add(skeleton.get("minor_ridge", []), 0, False)
|
||||
_add(skeleton.get("minor_valley", []), 1, False)
|
||||
_add(skeleton.get("main_ridge", []), 0, True)
|
||||
_add(skeleton.get("main_valley", []), 1, True)
|
||||
|
||||
if not pos:
|
||||
return (np.zeros((0, 3)), np.zeros(0, dtype=np.int8), np.zeros(0, dtype=bool))
|
||||
return (
|
||||
np.asarray(pos, dtype=np.float64),
|
||||
np.asarray(kind, dtype=np.int8),
|
||||
np.asarray(on_main, dtype=bool),
|
||||
)
|
||||
|
||||
|
||||
def _build_barrier_mask(grid: _Grid, skeleton: dict[str, Any]) -> np.ndarray:
|
||||
"""주능선/주계곡 셀을 True로 표시한 구획 경계 마스크."""
|
||||
barrier = np.zeros(grid.z.shape, dtype=bool)
|
||||
for key in ("main_ridge", "main_valley"):
|
||||
for item in skeleton.get(key, []):
|
||||
for p in item["polyline"]:
|
||||
r, c = grid.rc(p[0], p[1])
|
||||
barrier[r, c] = True
|
||||
return barrier
|
||||
|
||||
|
||||
def _segment_feasible(
|
||||
a: np.ndarray,
|
||||
b: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
enforce_grade_window: bool = True,
|
||||
) -> bool:
|
||||
"""a→b 직선이 정속경사 세그먼트로 성립하는지 검사한다."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return False
|
||||
design_grade = (b[2] - a[2]) / length
|
||||
g = abs(design_grade)
|
||||
if enforce_grade_window:
|
||||
if not (min_grade <= g <= max_grade):
|
||||
return False
|
||||
elif g > max_grade:
|
||||
return False
|
||||
|
||||
step = max(grid.res, 1.0)
|
||||
n_steps = max(int(length / step), 1)
|
||||
for i in range(n_steps + 1):
|
||||
t = i / n_steps
|
||||
px, py = a[0] + t * dx, a[1] + t * dy
|
||||
if not grid.valid_at(px, py):
|
||||
return False
|
||||
s = t * length
|
||||
z_design = a[2] + design_grade * s
|
||||
z_terrain = grid.z_at(px, py)
|
||||
allowed = tol * max(s, length - s) + grid.res
|
||||
if abs(z_terrain - z_design) > allowed:
|
||||
return False
|
||||
if min(s, length - s) > endpoint_free_m:
|
||||
r, c = grid.rc(px, py)
|
||||
if barrier[r, c]:
|
||||
return False
|
||||
for circ in blocked_circles:
|
||||
if math.hypot(px - circ["x"], py - circ["y"]) < circ["radius_m"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_edges(
|
||||
pos: np.ndarray,
|
||||
kind: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
min_grade: float,
|
||||
max_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> dict[int, list[tuple[int, float]]]:
|
||||
"""능선↔계곡 노드 쌍의 정속경사 직선 엣지를 만든다 (무방향, 길이 저장)."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
adj: dict[int, list[tuple[int, float]]] = {i: [] for i in range(len(pos))}
|
||||
if len(pos) == 0:
|
||||
return adj
|
||||
|
||||
ridge_idx = np.nonzero(kind == 0)[0]
|
||||
valley_idx = np.nonzero(kind == 1)[0]
|
||||
if len(ridge_idx) == 0 or len(valley_idx) == 0:
|
||||
return adj
|
||||
|
||||
valley_tree = cKDTree(pos[valley_idx, :2])
|
||||
for ri in ridge_idx:
|
||||
cand = valley_tree.query_ball_point(pos[ri, :2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(
|
||||
cand,
|
||||
key=lambda j: (
|
||||
(pos[ri, 0] - pos[valley_idx[j], 0]) ** 2
|
||||
+ (pos[ri, 1] - pos[valley_idx[j], 1]) ** 2
|
||||
),
|
||||
)
|
||||
added = 0
|
||||
for j in cand:
|
||||
vi = int(valley_idx[j])
|
||||
length = math.hypot(pos[ri, 0] - pos[vi, 0], pos[ri, 1] - pos[vi, 1])
|
||||
if length < MIN_EDGE_LEN_M:
|
||||
continue
|
||||
if pos[ri, 2] <= pos[vi, 2]:
|
||||
continue
|
||||
if not _segment_feasible(
|
||||
pos[ri],
|
||||
pos[vi],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
min_grade,
|
||||
max_grade,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
):
|
||||
continue
|
||||
adj[int(ri)].append((vi, length))
|
||||
adj[vi].append((int(ri), length))
|
||||
added += 1
|
||||
if added >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return adj
|
||||
|
||||
|
||||
def _endpoint_connectors(
|
||||
pt: dict[str, float],
|
||||
pos: np.ndarray,
|
||||
grid: _Grid,
|
||||
barrier: np.ndarray,
|
||||
blocked_circles: list[dict[str, float]],
|
||||
max_uphill_grade: float,
|
||||
max_downhill_grade: float,
|
||||
tol: float,
|
||||
endpoint_free_m: float,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""BP/CP/EP를 그래프 노드에 잇는 연결 세그먼트 후보."""
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
if len(pos) == 0:
|
||||
return []
|
||||
p = np.array([pt["x"], pt["y"], grid.z_at(pt["x"], pt["y"])])
|
||||
tree = cKDTree(pos[:, :2])
|
||||
cand = tree.query_ball_point(p[:2], MAX_EDGE_LEN_M)
|
||||
cand = sorted(cand, key=lambda j: (p[0] - pos[j, 0]) ** 2 + (p[1] - pos[j, 1]) ** 2)
|
||||
out = []
|
||||
for j in cand:
|
||||
length = math.hypot(p[0] - pos[j, 0], p[1] - pos[j, 1])
|
||||
if length < 1e-6:
|
||||
out.append((int(j), max(length, 0.01)))
|
||||
continue
|
||||
applicable = max_uphill_grade if pos[j, 2] > p[2] else max_downhill_grade
|
||||
if _segment_feasible(
|
||||
p,
|
||||
pos[j],
|
||||
grid,
|
||||
barrier,
|
||||
blocked_circles,
|
||||
0.0,
|
||||
applicable,
|
||||
tol,
|
||||
endpoint_free_m,
|
||||
enforce_grade_window=False,
|
||||
):
|
||||
out.append((int(j), length))
|
||||
if len(out) >= MAX_NEIGHBORS_PER_NODE:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _turn_angle(p_prev, p_curr, p_next) -> float:
|
||||
"""진행방향 변화(교각) [rad]. 0 = 직진."""
|
||||
v1 = (p_curr[0] - p_prev[0], p_curr[1] - p_prev[1])
|
||||
v2 = (p_next[0] - p_curr[0], p_next[1] - p_curr[1])
|
||||
n1, n2 = math.hypot(*v1), math.hypot(*v2)
|
||||
if n1 < 1e-9 or n2 < 1e-9:
|
||||
return 0.0
|
||||
cosang = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
|
||||
return math.acos(cosang)
|
||||
@@ -19,7 +19,6 @@ from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profi
|
||||
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B05_Profile.B05_Profile_Repository import (
|
||||
confirm_route,
|
||||
create_route,
|
||||
create_route_statistics,
|
||||
get_latest_route,
|
||||
@@ -28,10 +27,8 @@ from B05_Profile.B05_Profile_Repository import (
|
||||
insert_route_points,
|
||||
update_longitudinal_grade_summary,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||
_append_irregular_cross_sections,
|
||||
_merge_uphill_overrides_into_longitudinal,
|
||||
sync_uphill_overrides_into_designs,
|
||||
from B05_Profile.B05_Profile_Router_Lifecycle import ( # noqa: F401 — 자동설계 체인이 이 경로로 부른다
|
||||
confirm_latest_route,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import (
|
||||
GRADE_PERCENT_FIELDS,
|
||||
@@ -39,8 +36,6 @@ from B05_Profile.B05_Profile_Schema import (
|
||||
ContourIntervalUpdateResponse,
|
||||
ProfileAlignmentSaveRequest,
|
||||
ProfileAlignmentSaveResponse,
|
||||
RouteConfirmRequest,
|
||||
RouteConfirmResponse,
|
||||
RouteLatestResponse,
|
||||
RouteSolveRequest,
|
||||
RouteSolveResponse,
|
||||
@@ -61,7 +56,6 @@ from common_util.common_util_surface_confirmation import (
|
||||
update_contour_interval_param,
|
||||
)
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
fail_stage,
|
||||
get_workflow_state,
|
||||
start_stage,
|
||||
@@ -495,228 +489,3 @@ async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONRespo
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "최신 경로 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
||||
async def confirm_latest_route(
|
||||
project_id: UUID,
|
||||
request: RouteConfirmRequest | None = None,
|
||||
mark_stage_complete: bool = True,
|
||||
) -> RouteConfirmResponse | JSONResponse:
|
||||
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다.
|
||||
|
||||
비정규 측점(구조물)이 있으면 확정 시 해당 측점의 횡단을 생성해 종단 파일에 병합한다.
|
||||
이 생성은 **비치명적**이다 — 실패해도 경로 확정(다음 단계 진행)은 그대로 진행한다.
|
||||
|
||||
`mark_stage_complete=False`는 자동 계산 체인용 — 데이터는 CONFIRMED로 저장하되
|
||||
stage 2를 IN_PROGRESS(사용자 검토 대기, 스텝바 노란 표시)로 남긴다. stage 2 완료는
|
||||
B06 종횡단 [확정]에서 stage 3과 함께 처리한다(2026-08-08 워크플로우 재정의).
|
||||
"""
|
||||
request = request or RouteConfirmRequest()
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 경로가 없습니다."},
|
||||
)
|
||||
if request.can_regenerate():
|
||||
try:
|
||||
await _append_irregular_cross_sections(connection, project_id, latest, request)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): "
|
||||
"project_id=%s route_id=%s",
|
||||
project_id,
|
||||
latest["id"],
|
||||
)
|
||||
# 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적.
|
||||
if request.uphill_overrides:
|
||||
try:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
longitudinal = await get_longitudinal_section(
|
||||
connection, project_id, latest["id"]
|
||||
)
|
||||
if longitudinal:
|
||||
overrides = [item.model_dump() for item in request.uphill_overrides]
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
await asyncio.to_thread(
|
||||
_merge_uphill_overrides_into_longitudinal,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
overrides,
|
||||
)
|
||||
# 저장된 횡단 설계의 절토측·측구측도 새 방향으로 재계산 — 정본만
|
||||
# 바꾸면 B06 표시·역반영이 옛 방향을 고수한다(2026-08-06 13측점).
|
||||
await sync_uphill_overrides_into_designs(
|
||||
connection,
|
||||
project_id,
|
||||
latest["id"],
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
overrides,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s",
|
||||
project_id,
|
||||
latest["id"],
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
log_b05_debug(
|
||||
logger,
|
||||
"db.routes.confirm",
|
||||
project_id=str(project_id),
|
||||
route_id=latest["id"],
|
||||
previous_status=latest["status"],
|
||||
next_status="CONFIRMED",
|
||||
)
|
||||
await confirm_route(connection, latest["id"])
|
||||
if mark_stage_complete:
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 2)
|
||||
await connection.commit()
|
||||
log_b05_debug(
|
||||
logger,
|
||||
"db.route_confirmation.committed",
|
||||
project_id=str(project_id),
|
||||
route_id=latest["id"],
|
||||
completed_stage=2 if mark_stage_complete else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
await connection.rollback()
|
||||
log_b05_debug(
|
||||
logger,
|
||||
"db.route_confirmation.rolled_back",
|
||||
project_id=str(project_id),
|
||||
route_id=latest["id"],
|
||||
reason=str(exc),
|
||||
)
|
||||
raise
|
||||
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
|
||||
except Exception:
|
||||
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "경로 확정 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/reset")
|
||||
async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
|
||||
|
||||
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
|
||||
체인 직후 떠 둔 `initial_snapshot/`의 DB 덤프와 정본 파일을 그대로 되돌려 놓는다 —
|
||||
재계산이 아니다. 재계산으로는 초기값이 나오지 않는다: `structures.json`과
|
||||
`edits/pipe_points.json`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시
|
||||
파생되기 때문이다.
|
||||
|
||||
스냅샷이 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든
|
||||
사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage 2·3은
|
||||
IN_PROGRESS(검토 대기)가 된다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
|
||||
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
|
||||
from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
has_initial_snapshot,
|
||||
restore_initial_snapshot,
|
||||
restore_snapshot_files,
|
||||
wipe_edited_masters,
|
||||
)
|
||||
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
|
||||
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path)) if stored_path else None
|
||||
restored = bool(project_root and has_initial_snapshot(project_root))
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
# 확정 지표면 모델을 초기 체인과 같은 기준(config 기본값)으로 다시 찾는다.
|
||||
try:
|
||||
surface_model_id: int | None = await find_surface_model_for_selection(
|
||||
connection, project_id, surface_confirmation_defaults()
|
||||
)
|
||||
except Exception:
|
||||
surface_model_id = None
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM routes WHERE project_id = %s", (str(project_id),)
|
||||
)
|
||||
deleted = cursor.rowcount
|
||||
# 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다.
|
||||
if restored and project_root:
|
||||
await restore_initial_snapshot(connection, project_root, str(project_id))
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
if restored and project_root:
|
||||
# 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아
|
||||
# 초기값이 오염된다(2026-08-29).
|
||||
await asyncio.to_thread(restore_snapshot_files, project_root)
|
||||
else:
|
||||
# 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야
|
||||
# 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다
|
||||
# (2026-08-29 실측). 체인이 끝나며 그 결과를 초기값으로 촬영한다.
|
||||
if project_root:
|
||||
removed = await asyncio.to_thread(wipe_edited_masters, project_root)
|
||||
if removed:
|
||||
logger.info(
|
||||
"B05 초기화: 편집 정본 제거 %s (project_id=%s)", removed, project_id
|
||||
)
|
||||
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "초기값 복원에 실패했습니다."
|
||||
if restored
|
||||
else "초기 경로 재계산에 실패했습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
|
||||
try:
|
||||
if project_root:
|
||||
removed = await asyncio.to_thread(
|
||||
prune_corridor_files,
|
||||
project_root,
|
||||
{int(latest["id"])},
|
||||
)
|
||||
if removed:
|
||||
logger.info(
|
||||
"B05 초기화: 주인 없는 코리도 파일 %d개 삭제 (project_id=%s)",
|
||||
removed,
|
||||
project_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 정리 실패가 초기화를 막지는 않는다
|
||||
logger.exception("B05 초기화: 코리도 파일 정리 실패 (project_id=%s)", project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": latest["id"],
|
||||
"deleted_routes": deleted,
|
||||
"restored": restored,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 설계 초기화 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "설계 초기화 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""B05 경로 확정·초기화 엔드포인트.
|
||||
|
||||
`B05_Profile_Router` 에서 떼어낸 뒷단이다(700줄 제한, 2026-09-02). URL·응답은 그대로고
|
||||
라우터 객체만 따로 두어 `main.py` 가 함께 등록한다. 확정 보조 함수는 종전대로
|
||||
`B05_Profile_Router_Confirm` 에 있다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
||||
from B05_Profile.B05_Profile_Repository import confirm_route, get_latest_route
|
||||
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||
_append_irregular_cross_sections,
|
||||
_merge_uphill_overrides_into_longitudinal,
|
||||
sync_uphill_overrides_into_designs,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
|
||||
async def confirm_latest_route(
|
||||
project_id: UUID,
|
||||
request: RouteConfirmRequest | None = None,
|
||||
mark_stage_complete: bool = True,
|
||||
) -> RouteConfirmResponse | JSONResponse:
|
||||
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다.
|
||||
|
||||
비정규 측점(구조물)이 있으면 확정 시 해당 측점의 횡단을 생성해 종단 파일에 병합한다.
|
||||
이 생성은 **비치명적**이다 — 실패해도 경로 확정(다음 단계 진행)은 그대로 진행한다.
|
||||
|
||||
`mark_stage_complete=False`는 자동 계산 체인용 — 데이터는 CONFIRMED로 저장하되
|
||||
stage 2를 IN_PROGRESS(사용자 검토 대기, 스텝바 노란 표시)로 남긴다. stage 2 완료는
|
||||
B06 종횡단 [확정]에서 stage 3과 함께 처리한다(2026-08-08 워크플로우 재정의).
|
||||
"""
|
||||
request = request or RouteConfirmRequest()
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "확정할 경로가 없습니다."},
|
||||
)
|
||||
if request.can_regenerate():
|
||||
try:
|
||||
await _append_irregular_cross_sections(connection, project_id, latest, request)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): "
|
||||
"project_id=%s route_id=%s",
|
||||
project_id,
|
||||
latest["id"],
|
||||
)
|
||||
# 상단측(측구 방향) 사용자 변경분을 종단 정본에 병합한다 — 비치명적.
|
||||
if request.uphill_overrides:
|
||||
try:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
longitudinal = await get_longitudinal_section(
|
||||
connection, project_id, latest["id"]
|
||||
)
|
||||
if longitudinal:
|
||||
overrides = [item.model_dump() for item in request.uphill_overrides]
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
await asyncio.to_thread(
|
||||
_merge_uphill_overrides_into_longitudinal,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
overrides,
|
||||
)
|
||||
# 저장된 횡단 설계의 절토측·측구측도 새 방향으로 재계산 — 정본만
|
||||
# 바꾸면 B06 표시·역반영이 옛 방향을 고수한다(2026-08-06 13측점).
|
||||
await sync_uphill_overrides_into_designs(
|
||||
connection,
|
||||
project_id,
|
||||
latest["id"],
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
overrides,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 상단측 변경 병합 실패 (경로 확정은 진행): project_id=%s route_id=%s",
|
||||
project_id,
|
||||
latest["id"],
|
||||
)
|
||||
await connection.begin()
|
||||
try:
|
||||
log_b05_debug(
|
||||
logger,
|
||||
"db.routes.confirm",
|
||||
project_id=str(project_id),
|
||||
route_id=latest["id"],
|
||||
previous_status=latest["status"],
|
||||
next_status="CONFIRMED",
|
||||
)
|
||||
await confirm_route(connection, latest["id"])
|
||||
if mark_stage_complete:
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 2)
|
||||
await connection.commit()
|
||||
log_b05_debug(
|
||||
logger,
|
||||
"db.route_confirmation.committed",
|
||||
project_id=str(project_id),
|
||||
route_id=latest["id"],
|
||||
completed_stage=2 if mark_stage_complete else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
await connection.rollback()
|
||||
log_b05_debug(
|
||||
logger,
|
||||
"db.route_confirmation.rolled_back",
|
||||
project_id=str(project_id),
|
||||
route_id=latest["id"],
|
||||
reason=str(exc),
|
||||
)
|
||||
raise
|
||||
return RouteConfirmResponse(project_id=str(project_id), route_id=latest["id"])
|
||||
except Exception:
|
||||
logger.exception("B05 경로 확정 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "경로 확정 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/reset")
|
||||
async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
"""B05·B06 설계를 초기값으로 되돌린다 ([초기화] 버튼).
|
||||
|
||||
**초기값 스냅샷이 있으면 복원한다**(2026-08-29 사용자 확정, CLAUDE.md 5장). 자동설계
|
||||
체인 직후 떠 둔 `initial_snapshot/`의 DB 덤프와 정본 파일을 그대로 되돌려 놓는다 —
|
||||
재계산이 아니다. 재계산으로는 초기값이 나오지 않는다: `structures.json`과
|
||||
`edits/pipe_points.json`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시
|
||||
파생되기 때문이다.
|
||||
|
||||
스냅샷이 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든
|
||||
사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage 2·3은
|
||||
IN_PROGRESS(검토 대기)가 된다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
|
||||
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
|
||||
from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
has_initial_snapshot,
|
||||
restore_initial_snapshot,
|
||||
restore_snapshot_files,
|
||||
wipe_edited_masters,
|
||||
)
|
||||
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
|
||||
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path)) if stored_path else None
|
||||
restored = bool(project_root and has_initial_snapshot(project_root))
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
# 확정 지표면 모델을 초기 체인과 같은 기준(config 기본값)으로 다시 찾는다.
|
||||
try:
|
||||
surface_model_id: int | None = await find_surface_model_for_selection(
|
||||
connection, project_id, surface_confirmation_defaults()
|
||||
)
|
||||
except Exception:
|
||||
surface_model_id = None
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM routes WHERE project_id = %s", (str(project_id),)
|
||||
)
|
||||
deleted = cursor.rowcount
|
||||
# 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다.
|
||||
if restored and project_root:
|
||||
await restore_initial_snapshot(connection, project_root, str(project_id))
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
if restored and project_root:
|
||||
# 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아
|
||||
# 초기값이 오염된다(2026-08-29).
|
||||
await asyncio.to_thread(restore_snapshot_files, project_root)
|
||||
else:
|
||||
# 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야
|
||||
# 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다
|
||||
# (2026-08-29 실측). 체인이 끝나며 그 결과를 초기값으로 촬영한다.
|
||||
if project_root:
|
||||
removed = await asyncio.to_thread(wipe_edited_masters, project_root)
|
||||
if removed:
|
||||
logger.info(
|
||||
"B05 초기화: 편집 정본 제거 %s (project_id=%s)", removed, project_id
|
||||
)
|
||||
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
|
||||
|
||||
async with pool.acquire() as connection:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "초기값 복원에 실패했습니다."
|
||||
if restored
|
||||
else "초기 경로 재계산에 실패했습니다.",
|
||||
},
|
||||
)
|
||||
|
||||
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
|
||||
try:
|
||||
if project_root:
|
||||
removed = await asyncio.to_thread(
|
||||
prune_corridor_files,
|
||||
project_root,
|
||||
{int(latest["id"])},
|
||||
)
|
||||
if removed:
|
||||
logger.info(
|
||||
"B05 초기화: 주인 없는 코리도 파일 %d개 삭제 (project_id=%s)",
|
||||
removed,
|
||||
project_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 정리 실패가 초기화를 막지는 않는다
|
||||
logger.exception("B05 초기화: 코리도 파일 정리 실패 (project_id=%s)", project_id)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"route_id": latest["id"],
|
||||
"deleted_routes": deleted,
|
||||
"restored": restored,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 설계 초기화 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "설계 초기화 처리 중 오류가 발생했습니다."},
|
||||
)
|
||||
@@ -37,8 +37,10 @@ export interface CorridorStructureHook {
|
||||
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
|
||||
* 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27.
|
||||
* 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자).
|
||||
* 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가
|
||||
* 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02).
|
||||
*/
|
||||
const ENVELOPE_VERSION = 5;
|
||||
const ENVELOPE_VERSION = 6;
|
||||
|
||||
interface CorridorEnvelope {
|
||||
version: number;
|
||||
@@ -49,6 +51,13 @@ interface CorridorEnvelope {
|
||||
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). */
|
||||
@@ -142,6 +151,8 @@ function fnv1a(text: string): string {
|
||||
// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.)
|
||||
// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단
|
||||
// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로").
|
||||
// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에
|
||||
// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.)
|
||||
// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`).
|
||||
// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다
|
||||
// (2026-08-27 사용자). 안쪽은 안 자른다.
|
||||
@@ -203,7 +214,15 @@ function fnv1a(text: string): string {
|
||||
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
|
||||
// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와
|
||||
// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자).
|
||||
const BUILD_VERSION = 88;
|
||||
// 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셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다.
|
||||
const BUILD_VERSION = 90;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
@@ -280,6 +299,8 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
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
|
||||
? {
|
||||
@@ -307,6 +328,8 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
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
|
||||
? {
|
||||
|
||||
@@ -39,106 +39,27 @@ import {
|
||||
slerpLeft,
|
||||
type PieceControl,
|
||||
} from "./B05_Profile_UI_Corridor_Frames";
|
||||
import {
|
||||
buildCorridorStructures,
|
||||
type CorridorStructure,
|
||||
type RouteFrame,
|
||||
} from "./B05_Profile_UI_Corridor_Structures";
|
||||
import { buildCorridorStructures, type RouteFrame } from "./B05_Profile_UI_Corridor_Structures";
|
||||
export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
|
||||
import type {
|
||||
CorridorBuildResult,
|
||||
CorridorCap,
|
||||
CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build_Types";
|
||||
// 결과 자료형은 `_Build_Types` 에 있다(2026-09-02 분리) — 호출부 경로 유지를 위해 재수출.
|
||||
export type {
|
||||
CorridorBuildResult,
|
||||
CorridorCap,
|
||||
CorridorCutWall,
|
||||
CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build_Types";
|
||||
import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve";
|
||||
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
|
||||
import { buildPlanCurves, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
|
||||
import { buildPlanCurves } from "./B05_Profile_UI_Corridor_Plan";
|
||||
import { maskFillByPlanCurves } from "./B05_Profile_UI_Corridor_Cut";
|
||||
export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
|
||||
|
||||
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
|
||||
export interface CorridorRibbon {
|
||||
kind: CorridorKind;
|
||||
side: CorridorSide;
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
/**
|
||||
* 구조물 **패치 리본**(2026-08-25) — 투영커브로 잘린 자리에 다시 그리는 변형 성토면.
|
||||
* 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다).
|
||||
*/
|
||||
patch?: boolean;
|
||||
/**
|
||||
* 패치를 **절취 영역 안쪽으로 클립**한다(2026-08-27 사용자: 삐져나오지도 벌어지지도
|
||||
* 않게). `maskFillByPlanCurves`가 기본 성토면을 지우는 규칙의 **정확한 반대**를 건다 —
|
||||
* 영역 밖 셀은 지우고, 걸친 셀은 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유하니
|
||||
* 겹침도 구멍도 안 생긴다. **세월교 전용** — 구조물별로 로직이 다르다.
|
||||
*/
|
||||
patchClip?: boolean;
|
||||
/**
|
||||
* rowCount × colCount × 3 (모델 좌표).
|
||||
*
|
||||
* **Float64**다 — 모델 좌표는 EPSG 절대값(십만 m대)이라 Float32에 담으면 해상도가
|
||||
* 0.14m까지 떨어져, 0.2m로 세분한 점들이 양자화 계단을 타며 최대 35°짜리 지그재그가
|
||||
* 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다.
|
||||
*/
|
||||
positions: Float64Array;
|
||||
/**
|
||||
* 셀 마스크 — `(rowCount-1) × (colCount-1)`, 1이면 **그 셀은 안 그린다**
|
||||
* (2026-08-26 투영 커브 안쪽 지우기). 정점은 그대로 두고 삼각형만 뺀다.
|
||||
*/
|
||||
cellMask?: Uint8Array;
|
||||
/**
|
||||
* 축 고정 커브(BOX암거)가 **걸친 셀**을 커브에서 잘라 되살린 조각 삼각형
|
||||
* (모델 좌표, 삼각형당 9). 격자로는 못 담는 비스듬한 경계를 여기로 메운다
|
||||
* (2026-08-26 사용자: 이원화하여 관리).
|
||||
*/
|
||||
trimTris?: Float64Array;
|
||||
/** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */
|
||||
trimUvs?: Float32Array;
|
||||
/**
|
||||
* 격자점별 **원지반 표고**(rowCount × colCount) — 성토 리본에만 싣는다.
|
||||
* 잘린 자리 측벽을 성토면에서 원지반까지 세우는 밑선이다(2026-08-27 사용자).
|
||||
*/
|
||||
groundZ?: Float64Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잘린 자리 **측벽 서피스**(2026-08-27 사용자) — 리본 메시에 섞지 않고 구조물처럼
|
||||
* 따로 그린다. 삼각형 수프(정점당 3, 삼각형당 9). 빗금이 없어 UV는 담지 않는다.
|
||||
*/
|
||||
export interface CorridorCutWall {
|
||||
side: "left" | "right";
|
||||
positions: Float64Array;
|
||||
/** 패치 바깥 끝 스커트인가(2026-08-27) — 뷰어가 지형 색인으로 뒤늦게 붙이므로,
|
||||
* 다시 붙일 때 옛 것을 걷어내려면 표식이 있어야 한다(`_Corridor_Skirt.ts`). */
|
||||
patchSkirt?: boolean;
|
||||
/**
|
||||
* 정점별 UV(정점당 2) — **빗금 있는 성토 서피스**로 그릴 때만 싣는다.
|
||||
* 절취 측벽은 일반 서피스(빗금 없음)이고, 패치 스커트는 성토면의 연장이라 빗금을
|
||||
* 넣는다(2026-08-27 사용자: "변형 성토면 하단 끝단부 서피스는 메시 형태").
|
||||
*/
|
||||
uvs?: Float32Array;
|
||||
}
|
||||
|
||||
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
|
||||
export interface CorridorCap {
|
||||
/** [x, y, z설계, z지반] × N — offset 오름차순. */
|
||||
points: Array<[number, number, number, number]>;
|
||||
}
|
||||
|
||||
export interface CorridorBuildResult {
|
||||
ribbons: CorridorRibbon[];
|
||||
/** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */
|
||||
outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> };
|
||||
/** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */
|
||||
caps: CorridorCap[];
|
||||
/** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */
|
||||
structures: CorridorStructure[];
|
||||
/**
|
||||
* 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자) — 세트 구조물 상단 5m
|
||||
* 평면에 눕힌 닫힌 고리들. 구조물·비탈(변형/원본)을 유입·유출로 나눠 담는다.
|
||||
* 저장 코리도엔 없을 수 있다.
|
||||
*/
|
||||
planCurves?: PlanCurve[];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */
|
||||
cutWalls?: CorridorCutWall[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 구간 **성토 패치 리본** 적용 여부(2026-08-27 사용자). B06 횡단이 내는
|
||||
* 변형 성토선을 3D 포켓에 되메운다 — 다단이면 `성토 > 구조물 > 성토`라 성토선이
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Build_Types.ts
|
||||
* 코리도 빌더가 내는 **결과 자료형** — 리본·절취 측벽·캡·빌드 결과.
|
||||
*
|
||||
* `B05_Profile_UI_Corridor_Build` 에서 떼어냈다(700줄 제한, 2026-09-02). 계산은 전혀
|
||||
* 없고 모양과 그 뜻(주석)만 있다. 빌더가 그대로 다시 내보내므로 기존 import 경로
|
||||
* (`... from "./B05_Profile_UI_Corridor_Build"`)는 전부 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station";
|
||||
import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures";
|
||||
import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
|
||||
|
||||
export interface CorridorRibbon {
|
||||
kind: CorridorKind;
|
||||
side: CorridorSide;
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
/**
|
||||
* 구조물 **패치 리본**(2026-08-25) — 투영커브로 잘린 자리에 다시 그리는 변형 성토면.
|
||||
* 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다).
|
||||
*/
|
||||
patch?: boolean;
|
||||
/**
|
||||
* 패치를 **절취 영역 안쪽으로 클립**한다(2026-08-27 사용자: 삐져나오지도 벌어지지도
|
||||
* 않게). `maskFillByPlanCurves`가 기본 성토면을 지우는 규칙의 **정확한 반대**를 건다 —
|
||||
* 영역 밖 셀은 지우고, 걸친 셀은 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유하니
|
||||
* 겹침도 구멍도 안 생긴다. **세월교 전용** — 구조물별로 로직이 다르다.
|
||||
*/
|
||||
patchClip?: boolean;
|
||||
/**
|
||||
* rowCount × colCount × 3 (모델 좌표).
|
||||
*
|
||||
* **Float64**다 — 모델 좌표는 EPSG 절대값(십만 m대)이라 Float32에 담으면 해상도가
|
||||
* 0.14m까지 떨어져, 0.2m로 세분한 점들이 양자화 계단을 타며 최대 35°짜리 지그재그가
|
||||
* 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다.
|
||||
*/
|
||||
positions: Float64Array;
|
||||
/**
|
||||
* 셀 마스크 — `(rowCount-1) × (colCount-1)`, 1이면 **그 셀은 안 그린다**
|
||||
* (2026-08-26 투영 커브 안쪽 지우기). 정점은 그대로 두고 삼각형만 뺀다.
|
||||
*/
|
||||
cellMask?: Uint8Array;
|
||||
/**
|
||||
* 축 고정 커브(BOX암거)가 **걸친 셀**을 커브에서 잘라 되살린 조각 삼각형
|
||||
* (모델 좌표, 삼각형당 9). 격자로는 못 담는 비스듬한 경계를 여기로 메운다
|
||||
* (2026-08-26 사용자: 이원화하여 관리).
|
||||
*/
|
||||
trimTris?: Float64Array;
|
||||
/** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */
|
||||
trimUvs?: Float32Array;
|
||||
/**
|
||||
* 격자점별 **원지반 표고**(rowCount × colCount) — 성토 리본에만 싣는다.
|
||||
* 잘린 자리 측벽을 성토면에서 원지반까지 세우는 밑선이다(2026-08-27 사용자).
|
||||
*/
|
||||
groundZ?: Float64Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* 잘린 자리 **측벽 서피스**(2026-08-27 사용자) — 리본 메시에 섞지 않고 구조물처럼
|
||||
* 따로 그린다. 삼각형 수프(정점당 3, 삼각형당 9). 빗금이 없어 UV는 담지 않는다.
|
||||
*/
|
||||
export interface CorridorCutWall {
|
||||
side: "left" | "right";
|
||||
positions: Float64Array;
|
||||
/** 패치 바깥 끝 스커트인가(2026-08-27) — 뷰어가 지형 색인으로 뒤늦게 붙이므로,
|
||||
* 다시 붙일 때 옛 것을 걷어내려면 표식이 있어야 한다(`_Corridor_Skirt.ts`). */
|
||||
patchSkirt?: boolean;
|
||||
/**
|
||||
* 정점별 UV(정점당 2) — **빗금 있는 성토 서피스**로 그릴 때만 싣는다.
|
||||
* 절취 측벽은 일반 서피스(빗금 없음)이고, 패치 스커트는 성토면의 연장이라 빗금을
|
||||
* 넣는다(2026-08-27 사용자: "변형 성토면 하단 끝단부 서피스는 메시 형태").
|
||||
*/
|
||||
uvs?: Float32Array;
|
||||
}
|
||||
|
||||
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
|
||||
export interface CorridorCap {
|
||||
/** [x, y, z설계, z지반] × N — offset 오름차순. */
|
||||
points: Array<[number, number, number, number]>;
|
||||
}
|
||||
|
||||
export interface CorridorBuildResult {
|
||||
ribbons: CorridorRibbon[];
|
||||
/** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */
|
||||
outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> };
|
||||
/** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */
|
||||
caps: CorridorCap[];
|
||||
/** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */
|
||||
structures: CorridorStructure[];
|
||||
/**
|
||||
* 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자) — 세트 구조물 상단 5m
|
||||
* 평면에 눕힌 닫힌 고리들. 구조물·비탈(변형/원본)을 유입·유출로 나눠 담는다.
|
||||
* 저장 코리도엔 없을 수 있다.
|
||||
*/
|
||||
planCurves?: PlanCurve[];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */
|
||||
cutWalls?: CorridorCutWall[];
|
||||
}
|
||||
@@ -264,6 +264,7 @@ export function buildStructurePatchRibbons(
|
||||
* 날개 사다리꼴 자체가 없다. BOX·기슭막이는 종전 경로를 그대로 탄다.
|
||||
*/
|
||||
const isFord = section.ford != null;
|
||||
const isBox = section.box != null;
|
||||
const frameOf = (chainageM: number): RouteFrame | null => {
|
||||
const frame = frameAt(chainageM);
|
||||
if (!frame || !boxAxis) return frame;
|
||||
@@ -374,7 +375,11 @@ export function buildStructurePatchRibbons(
|
||||
chainages: buffer.map((row) => row.chainage),
|
||||
positions,
|
||||
patch: true,
|
||||
...(isFord ? { patchClip: true } : {}),
|
||||
// 절취 영역 안쪽으로 자르는 규칙은 세월교에만 걸려 있었다 — BOX암거 패치는
|
||||
// 한 번도 안 잘려 절취 영역 밖으로 넘쳤다(2026-09-02 실측: BOX 패치 리본
|
||||
// 두 벌에 셀 마스크가 없었다). BOX도 `wing-box` 커브로 절취 영역을 내므로
|
||||
// 같은 규칙을 태운다. 기슭막이(배수관 세트)는 날개 사다리꼴 자체가 없어 제외.
|
||||
...(isFord || isBox ? { patchClip: true } : {}),
|
||||
});
|
||||
}
|
||||
buffer = [];
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* 그대로는 그 부채꼴을 못 덮는다. 여기 도구들이 그 간극을 메운다.
|
||||
* · 종방향 — `wingSpanOf`(직선 투영, BOX암거용) / `wingSpanOnRoute`(노선 실투영, 세월교용)
|
||||
* · 횡방향 — `wingRangeAt`(행 횡단선 × 고리 교점의 안쪽·바깥쪽 끝)
|
||||
* · 줄 손질 — `extendRunTo` / `prependRunTo` / `clipRunToRange` / `clipRunBelow`
|
||||
* · 줄 손질 — `extendRunTo` / `prependRunTo` / `clipRunBelow`
|
||||
*
|
||||
* 「주황 바깥선 트림」(버전 76~78) 도구는 **뺐다**(2026-09-02) — 그 판이 사용자 지시로
|
||||
* 원복된 뒤 다시 배선되지 않아 참조가 0이었다. 이력은 `_Corridor.ts` 버전 주석에 남는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RouteFrame } from "./B05_Profile_UI_Corridor_Structures";
|
||||
@@ -251,150 +254,3 @@ export function extendRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoi
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 날개 하나의 트림선 — 바깥선 폴리라인과 그 날개가 노선에서 차지하는 구간. */
|
||||
export interface WingTrimLine {
|
||||
line: Array<[number, number]>;
|
||||
fromM: number;
|
||||
toM: number;
|
||||
}
|
||||
|
||||
/** 트림선이 모자랄 때 **끝 마디를 복사해 늘리는** 길이(m, 2026-08-27 사용자). */
|
||||
const WING_TRIM_EXTEND_M = 20;
|
||||
|
||||
/**
|
||||
* 주황 고리의 **바깥선**만 뽑아 양 끝을 늘린 트림선(2026-08-27 사용자: "주황색
|
||||
* 바깥선으로 잘라야 해 / 조금 모자라면 해당선을 복사해서 연장하고 트림").
|
||||
*
|
||||
* `wingBoxLoops`가 내는 고리 차례는 [시작 링, 끝 링, 끝 링→끝선 교점, …성토 끝선…,
|
||||
* 시작 링→끝선 교점, (닫기)]다. 바깥선 = 인덱스 2 ~ 끝-2 — 안쪽 날개벽 선은 뺀다.
|
||||
* 고리 종방향 밖 행은 교점이 없어 트림이 안 걸리므로, 양 끝 마디 방향으로
|
||||
* `WING_TRIM_EXTEND_M`만큼 **연장한 점을 붙여** 그 행에서도 만나게 한다.
|
||||
*/
|
||||
export function wingOuterLines(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
): WingTrimLine[] {
|
||||
const candidates = routeCandidates(chainageM, frameAt);
|
||||
if (!candidates.length) return [];
|
||||
const lines: WingTrimLine[] = [];
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) {
|
||||
if (loop.length < 5) continue;
|
||||
const line: Array<[number, number]> = loop
|
||||
.slice(2, loop.length - 1)
|
||||
.map((point) => [point[0], point[1]]);
|
||||
if (line.length < 2) continue;
|
||||
const stretch = (near: [number, number], far: [number, number]): [number, number] => {
|
||||
const dx = far[0] - near[0];
|
||||
const dy = far[1] - near[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 1e-9) return far;
|
||||
return [
|
||||
far[0] + (dx / length) * WING_TRIM_EXTEND_M,
|
||||
far[1] + (dy / length) * WING_TRIM_EXTEND_M,
|
||||
];
|
||||
};
|
||||
// 이 날개가 노선에서 차지하는 구간 — **행을 어느 날개 몫으로 볼지** 가른다.
|
||||
const spans = line.map(([x, y]) => projectChainage(candidates, x, y));
|
||||
line.unshift(stretch(line[1], line[0]));
|
||||
line.push(stretch(line[line.length - 2], line[line.length - 1]));
|
||||
lines.push({ line, fromM: Math.min(...spans), toM: Math.max(...spans) });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 행 횡단선이 **그 행을 맡은 날개**의 트림선과 만나는 편거리. 안 만나면 null.
|
||||
*
|
||||
* 날개는 **부재별로 구분한다**(2026-08-27 사용자: "구조물별로 구분되어야 함").
|
||||
* 세월교는 한 측에 유입·유출 날개가 각각 있어 트림선이 둘인데, 끝 마디를 20m 늘려
|
||||
* 놓아 서로의 구간까지 뻗는다. 그대로 합쳐서 가장 바깥을 고르면 유입 날개 선이
|
||||
* 유출 날개 행을 잘라 버린다 — 행마다 **누가거리가 가장 가까운 날개 하나만** 쓴다.
|
||||
*/
|
||||
export function wingTrimAt(
|
||||
lines: ReadonlyArray<WingTrimLine>,
|
||||
side: "left" | "right",
|
||||
frame: RouteFrame,
|
||||
at: number,
|
||||
): number | null {
|
||||
if (!lines.length) return null;
|
||||
let owner = lines[0];
|
||||
let bestGap = Infinity;
|
||||
for (const entry of lines) {
|
||||
const gap = Math.max(entry.fromM - at, at - entry.toM, 0);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
owner = entry;
|
||||
}
|
||||
}
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
let outer: number | null = null;
|
||||
{
|
||||
const line = owner.line;
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const [ax, ay] = line[i];
|
||||
const [bx, by] = line[i + 1];
|
||||
const ex = bx - ax;
|
||||
const ey = by - ay;
|
||||
const denominator = frame.leftX * ey - frame.leftY * ex;
|
||||
if (Math.abs(denominator) < 1e-12) continue;
|
||||
const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator;
|
||||
if (t < -1e-9 || t > 1 + 1e-9) continue;
|
||||
const o =
|
||||
Math.abs(frame.leftX) > Math.abs(frame.leftY)
|
||||
? (ax + ex * t - frame.cx) / frame.leftX
|
||||
: (ay + ey * t - frame.cy) / frame.leftY;
|
||||
if (Math.abs(o) > NEAR_LIMIT_M) continue;
|
||||
if (o * sign <= 0) continue;
|
||||
if (outer === null || (o - outer) * sign > 0) outer = o;
|
||||
}
|
||||
}
|
||||
return outer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 줄을 편거리 구간 `[from, to]`(부호 기준 안쪽→바깥)만 남기고 **자른다**. 경계를
|
||||
* 지나는 마디에는 교차점을 꽂아 격자 계단을 막는다.
|
||||
*
|
||||
* 날개 몫 행(구조물 점유 구간 밖)을 **주황 스케치의 바깥선**에서 끝내는 데 쓴다
|
||||
* (2026-08-27 사용자: "주황색 바깥선으로 잘라야 해"). 안쪽 경계로는 자르지 않는다 —
|
||||
* 자르면 구조물 구간과 날개 사이가 벌어진다. 벽 끝점 횡단선으로 자르던 것도 폐기
|
||||
* (스케치보다 바깥이라 스케치에 없는 자리까지 판이 남았다).
|
||||
*/
|
||||
export function clipRunToRange(
|
||||
run: OffsetPoint[],
|
||||
from: number,
|
||||
to: number,
|
||||
sign: number,
|
||||
): OffsetPoint[] {
|
||||
const inside = (point: OffsetPoint): boolean =>
|
||||
(point.offset_m - from) * sign >= -1e-9 && (to - point.offset_m) * sign >= -1e-9;
|
||||
const between = (a: OffsetPoint, b: OffsetPoint, offset: number): OffsetPoint => {
|
||||
const step = b.offset_m - a.offset_m;
|
||||
const ratio = Math.abs(step) < 1e-12 ? 0 : (offset - a.offset_m) / step;
|
||||
return {
|
||||
offset_m: offset,
|
||||
elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * ratio,
|
||||
};
|
||||
};
|
||||
const kept: OffsetPoint[] = [];
|
||||
for (let i = 0; i < run.length; i += 1) {
|
||||
const point = run[i];
|
||||
if (i > 0) {
|
||||
const previous = run[i - 1];
|
||||
const step = point.offset_m - previous.offset_m;
|
||||
const crossings = [from, to]
|
||||
.filter((bound) => (previous.offset_m - bound) * (point.offset_m - bound) < 0)
|
||||
.sort((a, b) => (step >= 0 ? a - b : b - a));
|
||||
for (const bound of crossings) kept.push(between(previous, point, bound));
|
||||
}
|
||||
if (inside(point)) kept.push(point);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
@@ -323,12 +323,24 @@ const COLLINEAR_TOLERANCE_M = 0.001;
|
||||
function dropCollinear(
|
||||
loop: ReadonlyArray<[number, number, number]>,
|
||||
): Array<[number, number, number]> {
|
||||
if (loop.length < 3) return [...loop];
|
||||
// 추적 고리는 머리 점이 꼬리에 한 번 더 실려 온다(닫힘 표시). **순환으로 다듬기 전에
|
||||
// 뗀다** — 그대로 두면 머리 점의 이웃이 자기 자신이 되어 `away = 0`으로 지워지고, 그
|
||||
// 모서리 양옆의 일직선 점들까지 함께 빠져 **시작 모서리가 대각선으로 잘려 나갔다**
|
||||
// (2026-09-02 실측: 고리마다 첫 행 쐐기 63셀이 되살릴 영역에서 빠져 구멍이 됐다).
|
||||
const closed =
|
||||
loop.length > 1 &&
|
||||
loop[0][0] === loop[loop.length - 1][0] &&
|
||||
loop[0][1] === loop[loop.length - 1][1];
|
||||
const ring = closed ? loop.slice(0, -1) : loop;
|
||||
if (ring.length < 3) return [...loop];
|
||||
const kept: Array<[number, number, number]> = [];
|
||||
for (let i = 0; i < loop.length; i += 1) {
|
||||
const prev = loop[(i - 1 + loop.length) % loop.length];
|
||||
const at = loop[i];
|
||||
const next = loop[(i + 1) % loop.length];
|
||||
for (let i = 0; i < ring.length; i += 1) {
|
||||
// 앞점은 **마지막으로 남긴 점**이다 — 바로 앞 원점으로 재면 완만한 곡선에서 점마다
|
||||
// 1mm 미만이라 전부 빠지고, 남은 긴 현이 곡선 안쪽으로 2cm 파고든다(2026-09-02 실측:
|
||||
// 직선부 노견 열의 셀 중심이 현 위에 놓였다). `_Region.ts` `simplifyLoop`과 같은 규칙.
|
||||
const prev = kept[kept.length - 1] ?? ring[ring.length - 1];
|
||||
const at = ring[i];
|
||||
const next = ring[(i + 1) % ring.length];
|
||||
const dx = next[0] - prev[0];
|
||||
const dy = next[1] - prev[1];
|
||||
const span = Math.hypot(dx, dy);
|
||||
@@ -339,6 +351,8 @@ function dropCollinear(
|
||||
}
|
||||
kept.push(at);
|
||||
}
|
||||
// 닫힘 표시는 그대로 돌려준다 — 스케치가 `Line`으로 그려져 꼬리 점이 있어야 닫힌다.
|
||||
if (closed && kept.length) kept.push(kept[0]);
|
||||
return kept;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,26 @@ const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
|
||||
"cut-wall": 0x000000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 평면 스케치(투영 커브) 묶음의 이름 — 뷰어가 이 이름으로 찾아 켜고 끈다.
|
||||
*
|
||||
* **최종 결과물에서는 숨긴다**(2026-09-02 사용자). 이 커브들은 구조물이 어디를 지우고
|
||||
* 어디를 다시 채우는지 눈으로 좇으려고 만든 것이라 설계 산출물이 아니다. 다만 절취·패치
|
||||
* 기하를 다시 들여다볼 때 쓸모가 있어 **지우지 않고 감춰만 둔다**.
|
||||
*/
|
||||
export const PLAN_CURVE_GROUP = "corridor-plan-sketch";
|
||||
|
||||
/** 평면 스케치를 켜 둔 브라우저인가 — 기본은 꺼짐. 디버깅용 스위치다. */
|
||||
export const PLAN_CURVE_FLAG = "frd_debug_plan_curves";
|
||||
|
||||
export function planCurvesVisible(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(PLAN_CURVE_FLAG) === "1";
|
||||
} catch {
|
||||
return false; // 저장소가 막힌 환경(사생활 보호 모드 등) — 꺼진 것으로 본다.
|
||||
}
|
||||
}
|
||||
|
||||
/** 유출측 파선 눈금(m) — 유입은 실선이다. */
|
||||
const PLAN_DASH_M = { dashSize: 0.6, gapSize: 0.4 };
|
||||
|
||||
@@ -391,6 +411,13 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
|
||||
// 탑뷰 투영 커브(2026-08-26 사용자) — 구조물 세트마다 **그 세트 구조물 상단 +5m**
|
||||
// 평면에 눕힌 닫힌 고리다. 종류는 색(구조물 노랑 / 변형 성토선 분홍 / 원본 하늘),
|
||||
// 유입·유출은 실선·파선으로 나눈다. 표기 전용 — 서피스는 안 건드린다.
|
||||
//
|
||||
// **최종 결과물에서는 숨긴다**(2026-09-02 사용자). 지우지는 않는다 — 절취·패치 기하를
|
||||
// 다시 들여다볼 때 쓰므로 묶음째 감춰 두고 `__corridorPlanCurves(true)` 로 되켠다.
|
||||
const planGroup = new THREE.Group();
|
||||
planGroup.name = PLAN_CURVE_GROUP;
|
||||
planGroup.visible = planCurvesVisible();
|
||||
group.add(planGroup);
|
||||
(build.planCurves ?? []).forEach((curve) => {
|
||||
// 벽 자리 커브는 표시용이 아니다 — 벽 서피스로만 나간다(2026-08-27 사용자).
|
||||
if (curve.source === "cut-wall") return;
|
||||
@@ -417,7 +444,7 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
|
||||
if (dashed) line.computeLineDistances();
|
||||
const station = curve.setChainageM.toFixed(2);
|
||||
line.name = `corridor-plan:${curve.source}:${curve.role}:${curve.side}:${station}:${index}`;
|
||||
group.add(line);
|
||||
planGroup.add(line);
|
||||
});
|
||||
});
|
||||
// 절취 측벽 — 리본과 분리된 **별도 서피스**(2026-08-27 사용자). 성토면과 같은 색·빗금이라
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
|
||||
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
|
||||
@@ -600,115 +601,5 @@ export function buildCorridorStructures(
|
||||
return solids;
|
||||
}
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
export function structureHashParts(section: CrossSection): Array<string | number> {
|
||||
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
|
||||
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
|
||||
const extras: Array<string | number> = [];
|
||||
const fordPave = section.ford_pavement;
|
||||
if (fordPave) {
|
||||
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
|
||||
}
|
||||
const ownRevet = section.revetment;
|
||||
if (ownRevet) {
|
||||
extras.push(
|
||||
"rv",
|
||||
ownRevet.start_m,
|
||||
ownRevet.end_m,
|
||||
ownRevet.anchor_m,
|
||||
ownRevet.height_m ?? "",
|
||||
ownRevet.side ?? "",
|
||||
ownRevet.form ?? "",
|
||||
ownRevet.tiers ?? "",
|
||||
ownRevet.lift_m ?? "",
|
||||
ownRevet.shift_m ?? "",
|
||||
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
|
||||
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
|
||||
);
|
||||
}
|
||||
const box = section.box;
|
||||
if (box) {
|
||||
return [
|
||||
...extras,
|
||||
"bx",
|
||||
box.inner_width_m,
|
||||
box.inner_height_m,
|
||||
box.span_m,
|
||||
box.cover_m,
|
||||
box.wing_in.slab_extend_m,
|
||||
box.wing_in.height_m ?? "",
|
||||
box.wing_out.slab_extend_m,
|
||||
box.wing_out.height_m ?? "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
...(["left", "right"] as const).map((side) => {
|
||||
const value = section.design?.box_adjust?.[side];
|
||||
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
|
||||
}),
|
||||
];
|
||||
}
|
||||
const ford = section.ford;
|
||||
if (ford) {
|
||||
const adjust = section.design?.ford_adjust;
|
||||
const wall = (role: "inlet" | "outlet"): string => {
|
||||
const value = adjust?.[role];
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
return [
|
||||
...extras,
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
ford.pipe_count,
|
||||
ford.span_m,
|
||||
ford.wing_in.slab_extend_m,
|
||||
ford.wing_out.slab_extend_m,
|
||||
wall("inlet"),
|
||||
wall("outlet"),
|
||||
];
|
||||
}
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return extras;
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
[
|
||||
spec.structure,
|
||||
spec.revet_form ?? "",
|
||||
spec.revet_height_m ?? "",
|
||||
spec.revet_length_m ?? "",
|
||||
spec.revet_before_m ?? "",
|
||||
spec.revet_after_m ?? "",
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
return [
|
||||
...extras,
|
||||
"cv",
|
||||
culvert.diameter_m,
|
||||
culvert.pipe_kind ?? "",
|
||||
side(culvert.inlet),
|
||||
side(culvert.outlet),
|
||||
section.design?.inlet_structure ?? "",
|
||||
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
revet
|
||||
? Object.keys(revet)
|
||||
.sort()
|
||||
.map((role) => {
|
||||
const value = revet[role];
|
||||
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
|
||||
})
|
||||
.join("|")
|
||||
: "",
|
||||
counts ? `${counts.outlet},${counts.basin}` : "",
|
||||
section.design?.revet_follow_grade === false ? "flatZ" : "",
|
||||
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
|
||||
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
|
||||
Object.entries(section.design?.extra_spans ?? {})
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
|
||||
.join("|"),
|
||||
section.design?.revet_link_detached === true ? "detached" : "",
|
||||
];
|
||||
}
|
||||
// 해시 요약은 `_Structures_Hash.ts` 에 있다(2026-09-02 분리) — 호출부 경로 유지를 위해 재수출.
|
||||
export { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Structures_Hash.ts
|
||||
* 저장 코리도 만료 판정용 **구조물 해시 요약**.
|
||||
*
|
||||
* `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02).
|
||||
* 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 —
|
||||
* 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것.
|
||||
* 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
export function structureHashParts(section: CrossSection): Array<string | number> {
|
||||
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
|
||||
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
|
||||
const extras: Array<string | number> = [];
|
||||
const fordPave = section.ford_pavement;
|
||||
if (fordPave) {
|
||||
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
|
||||
}
|
||||
const ownRevet = section.revetment;
|
||||
if (ownRevet) {
|
||||
extras.push(
|
||||
"rv",
|
||||
ownRevet.start_m,
|
||||
ownRevet.end_m,
|
||||
ownRevet.anchor_m,
|
||||
ownRevet.height_m ?? "",
|
||||
ownRevet.side ?? "",
|
||||
ownRevet.form ?? "",
|
||||
ownRevet.tiers ?? "",
|
||||
ownRevet.lift_m ?? "",
|
||||
ownRevet.shift_m ?? "",
|
||||
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
|
||||
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
|
||||
);
|
||||
}
|
||||
const box = section.box;
|
||||
if (box) {
|
||||
return [
|
||||
...extras,
|
||||
"bx",
|
||||
box.inner_width_m,
|
||||
box.inner_height_m,
|
||||
box.span_m,
|
||||
box.cover_m,
|
||||
box.wing_in.slab_extend_m,
|
||||
box.wing_in.height_m ?? "",
|
||||
box.wing_out.slab_extend_m,
|
||||
box.wing_out.height_m ?? "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
...(["left", "right"] as const).map((side) => {
|
||||
const value = section.design?.box_adjust?.[side];
|
||||
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
|
||||
}),
|
||||
];
|
||||
}
|
||||
const ford = section.ford;
|
||||
if (ford) {
|
||||
const adjust = section.design?.ford_adjust;
|
||||
const wall = (role: "inlet" | "outlet"): string => {
|
||||
const value = adjust?.[role];
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
return [
|
||||
...extras,
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
ford.pipe_count,
|
||||
ford.span_m,
|
||||
ford.wing_in.slab_extend_m,
|
||||
ford.wing_out.slab_extend_m,
|
||||
wall("inlet"),
|
||||
wall("outlet"),
|
||||
];
|
||||
}
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return extras;
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
[
|
||||
spec.structure,
|
||||
spec.revet_form ?? "",
|
||||
spec.revet_height_m ?? "",
|
||||
spec.revet_length_m ?? "",
|
||||
spec.revet_before_m ?? "",
|
||||
spec.revet_after_m ?? "",
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
return [
|
||||
...extras,
|
||||
"cv",
|
||||
culvert.diameter_m,
|
||||
culvert.pipe_kind ?? "",
|
||||
side(culvert.inlet),
|
||||
side(culvert.outlet),
|
||||
section.design?.inlet_structure ?? "",
|
||||
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
|
||||
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
|
||||
revet
|
||||
? Object.keys(revet)
|
||||
.sort()
|
||||
.map((role) => {
|
||||
const value = revet[role];
|
||||
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
|
||||
})
|
||||
.join("|")
|
||||
: "",
|
||||
counts ? `${counts.outlet},${counts.basin}` : "",
|
||||
section.design?.revet_follow_grade === false ? "flatZ" : "",
|
||||
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
|
||||
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
|
||||
Object.entries(section.design?.extra_spans ?? {})
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
|
||||
.join("|"),
|
||||
section.design?.revet_link_detached === true ? "detached" : "",
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import {
|
||||
computeDetailBasins,
|
||||
fetchDetailPipePoints,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
saveDetailPipePoints,
|
||||
type DetailBasin,
|
||||
type DetailBasinResponse,
|
||||
type PipeFacility,
|
||||
type PipeSource,
|
||||
type VWorldMeta,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -27,7 +25,7 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
|
||||
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
||||
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
||||
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
@@ -61,66 +59,9 @@ function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
|
||||
load: (projectId: string) => void;
|
||||
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
||||
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
||||
pipeChainages: () => number[];
|
||||
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
|
||||
movePipe: (fromChainage: number, toChainage: number) => void;
|
||||
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
|
||||
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
|
||||
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
|
||||
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때.
|
||||
* attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */
|
||||
addPipe: (chainageM: number, attributes?: FacilityAttributes) => void;
|
||||
/** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */
|
||||
updatePipeFacility: (
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
attributes: FacilityAttributes,
|
||||
) => void;
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
||||
savePipes: () => Promise<number>;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
selectBasinByChainage: (chainageM: number | null) => void;
|
||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
|
||||
selectPipeAtChainage: (chainageM: number | null) => void;
|
||||
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
|
||||
markStation: (chainageM: number | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface DrainagePanelCallbacks {
|
||||
/** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를
|
||||
* 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */
|
||||
onPipesChanged?: (
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
|
||||
design_flow_m3s?: number | null;
|
||||
facility: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
}>,
|
||||
) => void;
|
||||
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
|
||||
onBasinSelected?: (chainageM: number | null) => void;
|
||||
/** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면
|
||||
* (그래프·3D·리스트)이 같은 것을 가리키게 한다(2026-08-17 전역 선택 동기화). */
|
||||
onPipeSelected?: (chainageM: number | null) => void;
|
||||
/** 배수유역도 우클릭 빈 자리 메뉴 — 사이드 「구조물 배치」와 같은 구조물군 → 종류
|
||||
* 2단 항목(2026-08-18 일원화). 선택 = 폼 자동 지정(addAt 경유). */
|
||||
structureMenuItems?: (chainageM: number) => MapContextMenuItem[];
|
||||
}
|
||||
// 바깥 계약(창구·콜백)은 `_Types.ts` 에 있다(2026-09-02 분리) — 호출부 경로 유지를 위해 재수출.
|
||||
export type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types";
|
||||
import type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel {
|
||||
const chrome = createDrainageChrome(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Panel_Types.ts
|
||||
* 배수유역 패널의 **바깥 계약** — 패널이 내주는 창구(`DrainagePanel`)와 페이지가
|
||||
* 넘기는 콜백(`DrainagePanelCallbacks`).
|
||||
*
|
||||
* `B05_Profile_UI_Drainage_Panel` 에서 떼어냈다(700줄 제한, 2026-09-02). 패널이
|
||||
* 그대로 다시 내보내므로 기존 import 경로는 유효하다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
|
||||
export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
|
||||
load: (projectId: string) => void;
|
||||
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
||||
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
||||
pipeChainages: () => number[];
|
||||
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
|
||||
movePipe: (fromChainage: number, toChainage: number) => void;
|
||||
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
|
||||
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
|
||||
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
|
||||
/** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때.
|
||||
* attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */
|
||||
addPipe: (chainageM: number, attributes?: FacilityAttributes) => void;
|
||||
/** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */
|
||||
updatePipeFacility: (
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
attributes: FacilityAttributes,
|
||||
) => void;
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
||||
savePipes: () => Promise<number>;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
selectBasinByChainage: (chainageM: number | null) => void;
|
||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
|
||||
selectPipeAtChainage: (chainageM: number | null) => void;
|
||||
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
|
||||
markStation: (chainageM: number | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface DrainagePanelCallbacks {
|
||||
/** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를
|
||||
* 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */
|
||||
onPipesChanged?: (
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
|
||||
design_flow_m3s?: number | null;
|
||||
facility: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
}>,
|
||||
) => void;
|
||||
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
|
||||
onBasinSelected?: (chainageM: number | null) => void;
|
||||
/** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면
|
||||
* (그래프·3D·리스트)이 같은 것을 가리키게 한다(2026-08-17 전역 선택 동기화). */
|
||||
onPipeSelected?: (chainageM: number | null) => void;
|
||||
/** 배수유역도 우클릭 빈 자리 메뉴 — 사이드 「구조물 배치」와 같은 구조물군 → 종류
|
||||
* 2단 항목(2026-08-18 일원화). 선택 = 폼 자동 지정(addAt 경유). */
|
||||
structureMenuItems?: (chainageM: number) => MapContextMenuItem[];
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
@@ -21,13 +16,9 @@ import {
|
||||
type SurfaceModelSummary,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
clearRouteLatestCache,
|
||||
confirmRoute,
|
||||
fetchLatestRoute,
|
||||
readRouteLatestCache,
|
||||
resetRouteDesign,
|
||||
writeRouteLatestCache,
|
||||
solveRoute,
|
||||
updateContourInterval,
|
||||
type RouteLatestResponse,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
@@ -43,24 +34,22 @@ import {
|
||||
fetchSectionContext,
|
||||
type SectionDetailResponse,
|
||||
} from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import {
|
||||
invalidateSectionDetail,
|
||||
loadSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
||||
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
|
||||
import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
|
||||
import {
|
||||
resetDesignAction,
|
||||
solveRouteAction,
|
||||
tempSaveAction,
|
||||
type PageActionContext,
|
||||
} from "./B05_Profile_UI_Page_Actions";
|
||||
import "./B05_Profile_UI_Style.css";
|
||||
import "./B05_Profile_UI_Style_Structures.css";
|
||||
import {
|
||||
circlePoint,
|
||||
DEFAULT_ROAD_WIDTHS,
|
||||
fetchRoadWidths,
|
||||
interpolateIrregularStations,
|
||||
restorePoints,
|
||||
routePoint,
|
||||
toBounds,
|
||||
toGradeClass,
|
||||
} from "./B05_Profile_UI_Page_Helpers";
|
||||
@@ -241,14 +230,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
const panel = createRoutePanel({
|
||||
onSolve: () => void solve(),
|
||||
onTempSave: () => void tempSave(),
|
||||
onSolve: () => void solveRouteAction(actionContext),
|
||||
onTempSave: () => void tempSaveAction(actionContext),
|
||||
onGoCross: () => {
|
||||
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
||||
navigateTo(ROUTES.B06_SECTION);
|
||||
},
|
||||
onReset: () => void resetDesign(),
|
||||
onReset: () => void resetDesignAction(actionContext),
|
||||
onContourApply: (interval) => applyContours(interval),
|
||||
onSurfaceVisible: viewer.setSurfaceVisible,
|
||||
onCorridorVisible: (visible) => {
|
||||
@@ -565,151 +554,22 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function solve(): Promise<void> {
|
||||
if (!confirmedSurface || !latest) return;
|
||||
const points = viewer.markers.getPoints();
|
||||
if (!points.bp || !points.ep) {
|
||||
showToast("BP와 EP를 지형에 배치하세요.", "error");
|
||||
return;
|
||||
}
|
||||
const values = panel.values();
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const solved = await solveRoute(activeProjectId, {
|
||||
filter_key: latest.surface_params.source_filter,
|
||||
method: latest.surface_params.method,
|
||||
smooth: latest.surface_params.smooth,
|
||||
surface_model_id: confirmedSurface.id,
|
||||
algorithm: values.algorithm,
|
||||
bp: routePoint(points.bp),
|
||||
ep: routePoint(points.ep),
|
||||
cp: points.cp.map(routePoint),
|
||||
ap: points.ap.map(circlePoint),
|
||||
fp: points.fp.map(circlePoint),
|
||||
grade_class: values.gradeClass,
|
||||
paved: values.paved,
|
||||
min_curve_radius_m: values.minCurveRadius,
|
||||
max_uphill_grade: values.maxUphillGrade,
|
||||
max_downhill_grade: values.maxDownhillGrade,
|
||||
min_uphill_grade: values.minUphillGrade,
|
||||
min_downhill_grade: values.minDownhillGrade,
|
||||
allow_avoid_pass_through: values.allowAvoidPassThrough,
|
||||
station_interval_m: values.stationInterval,
|
||||
cross_half_width_m: null,
|
||||
cross_sample_interval_m: values.crossSampleInterval,
|
||||
long_sample_interval_m: values.longSampleInterval,
|
||||
terrain_type: values.terrainType,
|
||||
design_speed_kph: values.designSpeed,
|
||||
max_grade_pct: values.maxGradePct,
|
||||
min_vertical_radius_m: values.minVerticalRadius,
|
||||
min_tangent_length_m: values.minTangentLength,
|
||||
start_elevation_offset_m: values.startElevationOffset,
|
||||
end_elevation_offset_m: values.endElevationOffset,
|
||||
enforce_pipe_clearance: values.enforcePipeClearance,
|
||||
});
|
||||
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
|
||||
uphillOverrides.clear();
|
||||
persistUphillOverrides();
|
||||
renderLatest(await loadLatest(true));
|
||||
await restoreSections(solved.route_id);
|
||||
if (solved.cross_section_count === null) {
|
||||
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
|
||||
} else if (solved.grade_summary === null) {
|
||||
showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error");
|
||||
} else {
|
||||
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */
|
||||
async function tempSave(): Promise<void> {
|
||||
if (!routeReady) return;
|
||||
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다.
|
||||
await profilePanel.drainage.savePipes().catch(() => 0);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다.
|
||||
await profilePanel.save();
|
||||
// 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로
|
||||
// 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다.
|
||||
await bridge.saveStructuresIfDirty();
|
||||
// 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
|
||||
await confirmRoute(
|
||||
activeProjectId,
|
||||
{
|
||||
filter_key: latest?.surface_params.source_filter,
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: confirmedSurface?.id,
|
||||
irregular_stations: bridge.irregularStations().map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
|
||||
uphill_overrides: [...uphillOverrides.entries()].map(([chainage, side]) => ({
|
||||
chainage_m: Number(chainage),
|
||||
side,
|
||||
})),
|
||||
},
|
||||
false,
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
if (latest?.route?.id != null) {
|
||||
await flushCulvertOptions(
|
||||
activeProjectId,
|
||||
`b06:culvertopt:${activeProjectId}:${latest.route.id}`,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
|
||||
// 최종본으로 얹힌다(2026-08-24 사용자 지적).
|
||||
if (latest?.route?.id != null) {
|
||||
await saveCachedCrossPatches(activeProjectId, latest.route.id);
|
||||
}
|
||||
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
|
||||
invalidateSectionDetail(activeProjectId);
|
||||
renderLatest(await loadLatest(true));
|
||||
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
||||
showToast(L("B05_Route_TempSave_Success"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
|
||||
* 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다
|
||||
* (2026-08-19 진단) — 화면 안 모달로 확인받는다. */
|
||||
async function resetDesign(): Promise<void> {
|
||||
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetRouteDesign(activeProjectId);
|
||||
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
|
||||
clearRouteLatestCache(activeProjectId);
|
||||
invalidateSectionDetail(activeProjectId);
|
||||
// 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로
|
||||
// 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다.
|
||||
uphillOverrides.clear();
|
||||
persistUphillOverrides();
|
||||
clearStandardCrossSession(activeProjectId);
|
||||
showToast(L("B05_Route_Reset_Success"), "success");
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
// 버튼 동작 3종([경로 계산]·[임시저장]·[초기화])은 따로 뗀 모듈이 맡는다(2026-09-02).
|
||||
const actionContext: PageActionContext = {
|
||||
projectId: activeProjectId,
|
||||
latest: () => latest,
|
||||
confirmedSurface: () => confirmedSurface,
|
||||
routeReady: () => routeReady,
|
||||
viewer: () => viewer,
|
||||
panel: () => panel,
|
||||
profilePanel: () => profilePanel,
|
||||
bridge: () => bridge,
|
||||
uphillOverrides,
|
||||
persistUphillOverrides,
|
||||
loadLatest,
|
||||
renderLatest,
|
||||
restoreSections,
|
||||
};
|
||||
|
||||
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
|
||||
* 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Page_Actions.ts
|
||||
* B05 페이지의 **버튼 동작** — [경로 계산]·[임시저장]·[초기화].
|
||||
*
|
||||
* `B05_Profile_UI_Page` 에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩 순서는
|
||||
* 페이지에 남고, 여기에는 영구저장소를 건드리는 세 갈래만 둔다(CLAUDE.md 5장:
|
||||
* 세션에 쌓인 조작은 [저장]·[확정]에서만 정본으로 나간다).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import type { SurfaceModelSummary } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
clearRouteLatestCache,
|
||||
confirmRoute,
|
||||
resetRouteDesign,
|
||||
solveRoute,
|
||||
type RouteLatestResponse,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import {
|
||||
invalidateSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
|
||||
import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers";
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
||||
import type { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 버튼 동작이 페이지에서 받아 쓰는 창구 — 값은 바뀌므로 함수로 받는다. */
|
||||
export interface PageActionContext {
|
||||
projectId: string;
|
||||
latest: () => RouteLatestResponse | null;
|
||||
confirmedSurface: () => SurfaceModelSummary | null;
|
||||
routeReady: () => boolean;
|
||||
viewer: () => ReturnType<typeof createRouteViewer>;
|
||||
panel: () => ReturnType<typeof createRoutePanel>;
|
||||
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
|
||||
bridge: () => ReturnType<typeof createStructuresBridge>;
|
||||
/** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 문자열. */
|
||||
uphillOverrides: Map<string, "left" | "right">;
|
||||
persistUphillOverrides: () => void;
|
||||
loadLatest: (forceFresh?: boolean) => Promise<RouteLatestResponse>;
|
||||
renderLatest: (next: RouteLatestResponse) => void;
|
||||
restoreSections: (routeId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */
|
||||
export async function solveRouteAction(ctx: PageActionContext): Promise<void> {
|
||||
const latest = ctx.latest();
|
||||
const confirmedSurface = ctx.confirmedSurface();
|
||||
if (!confirmedSurface || !latest) return;
|
||||
const points = ctx.viewer().markers.getPoints();
|
||||
if (!points.bp || !points.ep) {
|
||||
showToast("BP와 EP를 지형에 배치하세요.", "error");
|
||||
return;
|
||||
}
|
||||
const values = ctx.panel().values();
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const solved = await solveRoute(ctx.projectId, {
|
||||
filter_key: latest.surface_params.source_filter,
|
||||
method: latest.surface_params.method,
|
||||
smooth: latest.surface_params.smooth,
|
||||
surface_model_id: confirmedSurface.id,
|
||||
algorithm: values.algorithm,
|
||||
bp: routePoint(points.bp),
|
||||
ep: routePoint(points.ep),
|
||||
cp: points.cp.map(routePoint),
|
||||
ap: points.ap.map(circlePoint),
|
||||
fp: points.fp.map(circlePoint),
|
||||
grade_class: values.gradeClass,
|
||||
paved: values.paved,
|
||||
min_curve_radius_m: values.minCurveRadius,
|
||||
max_uphill_grade: values.maxUphillGrade,
|
||||
max_downhill_grade: values.maxDownhillGrade,
|
||||
min_uphill_grade: values.minUphillGrade,
|
||||
min_downhill_grade: values.minDownhillGrade,
|
||||
allow_avoid_pass_through: values.allowAvoidPassThrough,
|
||||
station_interval_m: values.stationInterval,
|
||||
cross_half_width_m: null,
|
||||
cross_sample_interval_m: values.crossSampleInterval,
|
||||
long_sample_interval_m: values.longSampleInterval,
|
||||
terrain_type: values.terrainType,
|
||||
design_speed_kph: values.designSpeed,
|
||||
max_grade_pct: values.maxGradePct,
|
||||
min_vertical_radius_m: values.minVerticalRadius,
|
||||
min_tangent_length_m: values.minTangentLength,
|
||||
start_elevation_offset_m: values.startElevationOffset,
|
||||
end_elevation_offset_m: values.endElevationOffset,
|
||||
enforce_pipe_clearance: values.enforcePipeClearance,
|
||||
});
|
||||
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
|
||||
ctx.uphillOverrides.clear();
|
||||
ctx.persistUphillOverrides();
|
||||
ctx.renderLatest(await ctx.loadLatest(true));
|
||||
await ctx.restoreSections(solved.route_id);
|
||||
if (solved.cross_section_count === null) {
|
||||
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
|
||||
} else if (solved.grade_summary === null) {
|
||||
showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error");
|
||||
} else {
|
||||
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */
|
||||
export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
if (!ctx.routeReady()) return;
|
||||
const latest = ctx.latest();
|
||||
const projectId = ctx.projectId;
|
||||
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다.
|
||||
await ctx
|
||||
.profilePanel()
|
||||
.drainage.savePipes()
|
||||
.catch(() => 0);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다.
|
||||
await ctx.profilePanel().save();
|
||||
// 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로
|
||||
// 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다.
|
||||
await ctx.bridge().saveStructuresIfDirty();
|
||||
// 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
|
||||
await confirmRoute(
|
||||
projectId,
|
||||
{
|
||||
filter_key: latest?.surface_params.source_filter,
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: ctx.confirmedSurface()?.id,
|
||||
irregular_stations: ctx
|
||||
.bridge()
|
||||
.irregularStations()
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
|
||||
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
|
||||
chainage_m: Number(chainage),
|
||||
side,
|
||||
})),
|
||||
},
|
||||
false,
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
if (latest?.route?.id != null) {
|
||||
await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
|
||||
// 최종본으로 얹힌다(2026-08-24 사용자 지적).
|
||||
if (latest?.route?.id != null) {
|
||||
await saveCachedCrossPatches(projectId, latest.route.id);
|
||||
}
|
||||
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
|
||||
invalidateSectionDetail(projectId);
|
||||
ctx.renderLatest(await ctx.loadLatest(true));
|
||||
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id);
|
||||
showToast(L("B05_Route_TempSave_Success"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
|
||||
* 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다
|
||||
* (2026-08-19 진단) — 화면 안 모달로 확인받는다. */
|
||||
export async function resetDesignAction(ctx: PageActionContext): Promise<void> {
|
||||
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetRouteDesign(ctx.projectId);
|
||||
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
|
||||
clearRouteLatestCache(ctx.projectId);
|
||||
invalidateSectionDetail(ctx.projectId);
|
||||
// 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로
|
||||
// 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다.
|
||||
ctx.uphillOverrides.clear();
|
||||
ctx.persistUphillOverrides();
|
||||
clearStandardCrossSession(ctx.projectId);
|
||||
showToast(L("B05_Route_Reset_Success"), "success");
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,23 @@ import {
|
||||
sceneToModel,
|
||||
type ModelBounds,
|
||||
type RouteMarkers,
|
||||
type RoutePointKind,
|
||||
type SectionStationMarker,
|
||||
} from "./B05_Profile_UI_Markers";
|
||||
import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera";
|
||||
import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
|
||||
import {
|
||||
createCorridorGroup,
|
||||
PLAN_CURVE_FLAG,
|
||||
PLAN_CURVE_GROUP,
|
||||
} from "./B05_Profile_UI_Corridor_Mesh";
|
||||
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
|
||||
import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
|
||||
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
|
||||
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
|
||||
|
||||
type ViewKind = "iso" | "top" | "front" | "side";
|
||||
|
||||
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
|
||||
const DARK_VIEWER_BACKGROUND = 0x251f38;
|
||||
|
||||
@@ -178,10 +184,6 @@ export function createRouteViewer(): RouteViewer {
|
||||
let bounds: ModelBounds | null = null;
|
||||
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
|
||||
null;
|
||||
let movingSelected = false;
|
||||
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
|
||||
let draggingMarker = false;
|
||||
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
||||
/**
|
||||
* 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다.
|
||||
* 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다.
|
||||
@@ -203,8 +205,24 @@ export function createRouteViewer(): RouteViewer {
|
||||
toScene: (x: number, y: number, z: number) =>
|
||||
bounds ? modelToScene({ x, y, z }, bounds) : null,
|
||||
topZ: () => (bounds ? bounds.z[1] + 100 : null),
|
||||
// 카메라를 모델 좌표 한 점으로 바로 보낸다 — 측점 확인을 마우스 휠·드래그로 하면
|
||||
// 한 번에 20~30초가 걸리고 우클릭이 구조물 메뉴를 연다(2026-09-02). 화면 검증 전용.
|
||||
lookAt: (x: number, y: number, z: number, distance: number, view: ViewKind = "top") => {
|
||||
if (!bounds) return false;
|
||||
placeCamera(modelToScene({ x, y, z }, bounds), distance, view);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
|
||||
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
|
||||
const markerInput = bindMarkerPointerControls({
|
||||
canvas,
|
||||
camera,
|
||||
controls,
|
||||
markers,
|
||||
getTerrain: () => terrain,
|
||||
getBounds: () => bounds,
|
||||
});
|
||||
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
|
||||
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
|
||||
const releaseCursorPivot = bindCursorPivotControls({
|
||||
@@ -212,7 +230,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
controls,
|
||||
element: canvas,
|
||||
pickables: () => (terrain ? [terrain] : []),
|
||||
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
||||
blocked: () => markerInput.blocked(),
|
||||
scene,
|
||||
});
|
||||
|
||||
@@ -230,12 +248,8 @@ export function createRouteViewer(): RouteViewer {
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(root);
|
||||
|
||||
function fit(view: "iso" | "top" | "front" | "side" = "top"): void {
|
||||
if (!bounds) return;
|
||||
const width = bounds.x[1] - bounds.x[0];
|
||||
const depth = bounds.y[1] - bounds.y[0];
|
||||
const distance = Math.max(width, depth, 20) * 1.35;
|
||||
controls.target.set(0, 0, 0);
|
||||
function placeCamera(target: THREE.Vector3, distance: number, view: ViewKind): void {
|
||||
controls.target.copy(target);
|
||||
const positions = {
|
||||
iso: [distance, distance, distance],
|
||||
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
|
||||
@@ -244,7 +258,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
side: [distance, distance * 0.25, 0],
|
||||
} as const;
|
||||
const [x, y, z] = positions[view];
|
||||
camera.position.set(x, y, z);
|
||||
camera.position.set(target.x + x, target.y + y, target.z + z);
|
||||
camera.near = Math.max(0.1, distance / 1000);
|
||||
camera.far = distance * 10;
|
||||
// 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다.
|
||||
@@ -252,6 +266,13 @@ export function createRouteViewer(): RouteViewer {
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function fit(view: ViewKind = "top"): void {
|
||||
if (!bounds) return;
|
||||
const width = bounds.x[1] - bounds.x[0];
|
||||
const depth = bounds.y[1] - bounds.y[0];
|
||||
placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view);
|
||||
}
|
||||
|
||||
async function reloadContours(interval: number): Promise<void> {
|
||||
if (!current || !bounds) return;
|
||||
current.interval = interval;
|
||||
@@ -294,132 +315,6 @@ export function createRouteViewer(): RouteViewer {
|
||||
}
|
||||
|
||||
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */
|
||||
function terrainPointAt(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { x: number; y: number; z: number } | null {
|
||||
if (!terrain || !bounds) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const pointer = new THREE.Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hit = raycaster.intersectObject(terrain, true)[0];
|
||||
return hit ? sceneToModel(hit.point, bounds) : null;
|
||||
}
|
||||
|
||||
function terrainPoint(
|
||||
event: PointerEvent | DragEvent,
|
||||
): { x: number; y: number; z: number } | null {
|
||||
return terrainPointAt(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
canvas.addEventListener("dragover", (event) => event.preventDefault());
|
||||
canvas.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
const kind = event.dataTransfer?.getData("pointType") as RoutePointKind;
|
||||
const point = terrainPoint(event);
|
||||
if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point);
|
||||
});
|
||||
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const pointer = new THREE.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
return raycaster.intersectObject(markers.group, true)[0]?.object;
|
||||
}
|
||||
|
||||
function finishMarkerInteraction(selectCandidate: boolean): void {
|
||||
if (dragCandidate && (draggingMarker || selectCandidate)) {
|
||||
markers.selectPoint(dragCandidate.id);
|
||||
}
|
||||
if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) {
|
||||
canvas.releasePointerCapture(dragCandidate.pointerId);
|
||||
}
|
||||
controls.enabled = true;
|
||||
dragCandidate = null;
|
||||
draggingMarker = false;
|
||||
lastDragPoint = null;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0) return;
|
||||
const hit = markerHit(event);
|
||||
const pointId = markers.pointIdForObject(hit);
|
||||
if (pointId) {
|
||||
dragCandidate = {
|
||||
id: pointId,
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
draggingMarker = false;
|
||||
lastDragPoint = null;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (hit) {
|
||||
markers.selectObject(hit);
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (movingSelected) {
|
||||
const point = terrainPoint(event);
|
||||
if (point) markers.moveSelected(point);
|
||||
movingSelected = false;
|
||||
} else {
|
||||
markers.selectObject(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent): void {
|
||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
!draggingMarker &&
|
||||
Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3
|
||||
) {
|
||||
draggingMarker = true;
|
||||
controls.enabled = false;
|
||||
}
|
||||
if (!draggingMarker) return;
|
||||
const point = terrainPoint(event);
|
||||
if (!point) return;
|
||||
lastDragPoint = point;
|
||||
markers.movePoint(dragCandidate.id, point);
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent): void {
|
||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (draggingMarker) {
|
||||
const point = terrainPoint(event) ?? lastDragPoint;
|
||||
if (point) markers.movePoint(dragCandidate.id, point);
|
||||
}
|
||||
finishMarkerInteraction(!draggingMarker);
|
||||
}
|
||||
|
||||
function handlePointerExit(event: PointerEvent): void {
|
||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finishMarkerInteraction(false);
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", handlePointerDown, true);
|
||||
canvas.addEventListener("pointermove", handlePointerMove, true);
|
||||
canvas.addEventListener("pointerup", handlePointerUp, true);
|
||||
canvas.addEventListener("pointerleave", handlePointerExit, true);
|
||||
canvas.addEventListener("pointercancel", handlePointerExit, true);
|
||||
|
||||
let frame = 0;
|
||||
function animate(): void {
|
||||
@@ -671,6 +566,24 @@ export function createRouteViewer(): RouteViewer {
|
||||
corridorGroup = createCorridorGroup(build, bounds);
|
||||
scene.add(corridorGroup);
|
||||
}
|
||||
// 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다
|
||||
// (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음").
|
||||
// `__corridorPlanCurves(true)` 로 켜고 `(false)` 로 끈다. 선택은 이 브라우저에 남아
|
||||
// 다음에 열 때도 그대로다. 인자 없이 부르면 지금 상태를 돌려준다.
|
||||
(
|
||||
window as unknown as { __corridorPlanCurves?: (on?: boolean) => boolean }
|
||||
).__corridorPlanCurves = (on?: boolean): boolean => {
|
||||
const sketch = corridorGroup?.getObjectByName(PLAN_CURVE_GROUP);
|
||||
if (on !== undefined) {
|
||||
try {
|
||||
window.localStorage.setItem(PLAN_CURVE_FLAG, on ? "1" : "0");
|
||||
} catch {
|
||||
// 저장소가 막힌 환경 — 이번 화면에만 적용한다.
|
||||
}
|
||||
if (sketch) sketch.visible = on;
|
||||
}
|
||||
return sketch?.visible ?? false;
|
||||
};
|
||||
scheduleTerrainClip();
|
||||
}
|
||||
|
||||
@@ -740,20 +653,16 @@ export function createRouteViewer(): RouteViewer {
|
||||
renderStationLines: markers.renderStationLines,
|
||||
setView: fit,
|
||||
beginMoveSelected() {
|
||||
movingSelected = true;
|
||||
markerInput.beginMoveSelected();
|
||||
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
|
||||
},
|
||||
modelPointAt: terrainPointAt,
|
||||
modelPointAt: markerInput.terrainPointAt,
|
||||
dispose() {
|
||||
markerInput.dispose();
|
||||
cancelAnimationFrame(frame);
|
||||
resizeObserver.disconnect();
|
||||
themeObserver.disconnect();
|
||||
systemDarkTheme.removeEventListener("change", updateSceneBackground);
|
||||
canvas.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
canvas.removeEventListener("pointermove", handlePointerMove, true);
|
||||
canvas.removeEventListener("pointerup", handlePointerUp, true);
|
||||
canvas.removeEventListener("pointerleave", handlePointerExit, true);
|
||||
canvas.removeEventListener("pointercancel", handlePointerExit, true);
|
||||
releaseCursorPivot();
|
||||
markers.dispose();
|
||||
clearContours();
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Viewer_Marker_Input.ts
|
||||
* 3D 뷰어의 **포인터 입력** — 지형 위 좌표 얻기, 마커 끌기·고르기, 끌어놓기(drop).
|
||||
*
|
||||
* `B05_Profile_UI_Viewer` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 카메라·씬 조립과
|
||||
* 코리도 조립은 뷰어에 남고, 여기에는 캔버스 이벤트로 마커를 다루는 흐름만 둔다.
|
||||
* 회전 중심 유틸(`bindCursorPivotControls`)이 "지금 마커를 잡고 있나"를 묻는 창구도
|
||||
* 여기서 낸다(`blocked`).
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import {
|
||||
sceneToModel,
|
||||
type ModelBounds,
|
||||
type RouteMarkers,
|
||||
type RoutePointKind,
|
||||
} from "./B05_Profile_UI_Markers";
|
||||
|
||||
export interface MarkerPointerControls {
|
||||
/** 화면 좌표 아래 지형 지점(모델 좌표). 지형·bounds가 없으면 null. */
|
||||
terrainPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null;
|
||||
/** 다음 클릭으로 선택 포인트를 옮기는 모드로 들어간다. */
|
||||
beginMoveSelected: () => void;
|
||||
/** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */
|
||||
blocked: () => boolean;
|
||||
/** 등록한 캔버스 이벤트를 모두 떼어낸다. */
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function bindMarkerPointerControls(options: {
|
||||
canvas: HTMLCanvasElement;
|
||||
camera: THREE.Camera;
|
||||
controls: OrbitControls;
|
||||
markers: RouteMarkers;
|
||||
getTerrain: () => THREE.Object3D | null;
|
||||
getBounds: () => ModelBounds | null;
|
||||
}): MarkerPointerControls {
|
||||
const { canvas, camera, controls, markers, getTerrain, getBounds } = options;
|
||||
|
||||
let movingSelected = false;
|
||||
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
|
||||
let draggingMarker = false;
|
||||
let lastDragPoint: { x: number; y: number; z: number } | null = null;
|
||||
|
||||
/** 화면 좌표 → 정규화 장치 좌표(-1~1). */
|
||||
function pointerOf(clientX: number, clientY: number): THREE.Vector2 {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return new THREE.Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function terrainPointAt(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): { x: number; y: number; z: number } | null {
|
||||
const terrain = getTerrain();
|
||||
const bounds = getBounds();
|
||||
if (!terrain || !bounds) return null;
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointerOf(clientX, clientY), camera);
|
||||
const hit = raycaster.intersectObject(terrain, true)[0];
|
||||
return hit ? sceneToModel(hit.point, bounds) : null;
|
||||
}
|
||||
|
||||
function terrainPoint(
|
||||
event: PointerEvent | DragEvent,
|
||||
): { x: number; y: number; z: number } | null {
|
||||
return terrainPointAt(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
const kind = event.dataTransfer?.getData("pointType") as RoutePointKind;
|
||||
const point = terrainPoint(event);
|
||||
if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point);
|
||||
}
|
||||
|
||||
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera);
|
||||
return raycaster.intersectObject(markers.group, true)[0]?.object;
|
||||
}
|
||||
|
||||
function finishMarkerInteraction(selectCandidate: boolean): void {
|
||||
if (dragCandidate && (draggingMarker || selectCandidate)) {
|
||||
markers.selectPoint(dragCandidate.id);
|
||||
}
|
||||
if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) {
|
||||
canvas.releasePointerCapture(dragCandidate.pointerId);
|
||||
}
|
||||
controls.enabled = true;
|
||||
dragCandidate = null;
|
||||
draggingMarker = false;
|
||||
lastDragPoint = null;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent): void {
|
||||
if (event.button !== 0) return;
|
||||
const hit = markerHit(event);
|
||||
const pointId = markers.pointIdForObject(hit);
|
||||
if (pointId) {
|
||||
dragCandidate = {
|
||||
id: pointId,
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
draggingMarker = false;
|
||||
lastDragPoint = null;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (hit) {
|
||||
markers.selectObject(hit);
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (movingSelected) {
|
||||
const point = terrainPoint(event);
|
||||
if (point) markers.moveSelected(point);
|
||||
movingSelected = false;
|
||||
} else {
|
||||
markers.selectObject(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent): void {
|
||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
!draggingMarker &&
|
||||
Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3
|
||||
) {
|
||||
draggingMarker = true;
|
||||
controls.enabled = false;
|
||||
}
|
||||
if (!draggingMarker) return;
|
||||
const point = terrainPoint(event);
|
||||
if (!point) return;
|
||||
lastDragPoint = point;
|
||||
markers.movePoint(dragCandidate.id, point);
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent): void {
|
||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (draggingMarker) {
|
||||
const point = terrainPoint(event) ?? lastDragPoint;
|
||||
if (point) markers.movePoint(dragCandidate.id, point);
|
||||
}
|
||||
finishMarkerInteraction(!draggingMarker);
|
||||
}
|
||||
|
||||
function handlePointerExit(event: PointerEvent): void {
|
||||
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finishMarkerInteraction(false);
|
||||
}
|
||||
|
||||
canvas.addEventListener("dragover", handleDragOver);
|
||||
canvas.addEventListener("drop", handleDrop);
|
||||
canvas.addEventListener("pointerdown", handlePointerDown, true);
|
||||
canvas.addEventListener("pointermove", handlePointerMove, true);
|
||||
canvas.addEventListener("pointerup", handlePointerUp, true);
|
||||
canvas.addEventListener("pointerleave", handlePointerExit, true);
|
||||
canvas.addEventListener("pointercancel", handlePointerExit, true);
|
||||
|
||||
return {
|
||||
terrainPointAt,
|
||||
beginMoveSelected: () => {
|
||||
movingSelected = true;
|
||||
},
|
||||
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
|
||||
dispose: () => {
|
||||
canvas.removeEventListener("dragover", handleDragOver);
|
||||
canvas.removeEventListener("drop", handleDrop);
|
||||
canvas.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
canvas.removeEventListener("pointermove", handlePointerMove, true);
|
||||
canvas.removeEventListener("pointerup", handlePointerUp, true);
|
||||
canvas.removeEventListener("pointerleave", handlePointerExit, true);
|
||||
canvas.removeEventListener("pointercancel", handlePointerExit, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,494 +12,26 @@
|
||||
* 규칙:
|
||||
* - 모든 제어 상수는 config_frontend에서 참조 (하드코딩 금지).
|
||||
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
|
||||
* - **타입 선언은 `B06_Section_Api_Types.ts`에 있다**(700줄 제한, 2026-09-02).
|
||||
* 기존 호출 코드가 깨지지 않도록 여기서 그대로 다시 내보낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import type {
|
||||
BalloonOffsets,
|
||||
EarthworkConversion,
|
||||
GroundType,
|
||||
HaulEquipmentLimit,
|
||||
} from "@util/common_util_mass_haul_types";
|
||||
CrossDesign,
|
||||
CrossDesignRequest,
|
||||
CrossDesignResponse,
|
||||
CrossSectionPatch,
|
||||
SectionConfirmResponse,
|
||||
SectionContextResponse,
|
||||
SectionDetailResponse,
|
||||
SectionSummaryResponse,
|
||||
StandardCrossSection,
|
||||
} from "./B06_Section_Api_Types";
|
||||
|
||||
export interface SectionOptionDefaults {
|
||||
station_interval_m: number;
|
||||
cross_half_width_m: number;
|
||||
cross_sample_interval_m: number;
|
||||
long_sample_interval_m: number;
|
||||
vertical_exaggeration: number;
|
||||
}
|
||||
|
||||
/** 측구 규격(상단폭/저폭/깊이, m). */
|
||||
export interface DitchSpec {
|
||||
top_width_m: number;
|
||||
bottom_width_m: number;
|
||||
depth_m: number;
|
||||
}
|
||||
|
||||
/** 지반그룹 하나의 표준 횡단면 기본값 (config STANDARD_CROSS_SECTION 사본). */
|
||||
export interface StandardCrossGroup {
|
||||
road_width_m: number;
|
||||
shoulder_left_m: number;
|
||||
shoulder_right_m: number;
|
||||
ditch: DitchSpec;
|
||||
/** 암 그룹만 존재: L형 측구(폭/깊이, m). */
|
||||
ditch_l_type?: { width_m: number; depth_m: number };
|
||||
cross_slope_pct: { min: number; max: number };
|
||||
fill_slope_ratio: number;
|
||||
cut_slope_ratio: number;
|
||||
/** 포장 그룹만 존재: 포장층 두께(m). */
|
||||
pavement_thickness_m?: number;
|
||||
}
|
||||
|
||||
/** 표준 횡단면 설정 패널 그룹 키. */
|
||||
export type StandardCrossKey = "soil" | "rock" | "paved";
|
||||
|
||||
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>;
|
||||
|
||||
// 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를
|
||||
// `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지).
|
||||
export type {
|
||||
GroundType,
|
||||
EarthworkConversionFactor,
|
||||
EarthworkConversion,
|
||||
HaulEquipmentLimit,
|
||||
BalloonOffsets,
|
||||
} from "@util/common_util_mass_haul_types";
|
||||
|
||||
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
|
||||
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
|
||||
export type { FordPavementSpec, RevetmentSpec };
|
||||
|
||||
export interface SectionContextResponse {
|
||||
project_id: string;
|
||||
route_id: number | null;
|
||||
filter_key: string | null;
|
||||
method: string | null;
|
||||
smooth: boolean | null;
|
||||
crs_epsg: number | null;
|
||||
/** 프로젝트 등록(B02)에서 정한 임도 종류 — B05 계획선 법정 기준의 출발점(2026-08-19). */
|
||||
road_type: string | null;
|
||||
defaults: SectionOptionDefaults;
|
||||
/** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */
|
||||
standard_cross_section: StandardCrossSection;
|
||||
/** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */
|
||||
rock_boundary_default_offset_m: number;
|
||||
rock_boundary_step_m: number;
|
||||
/** 지반유형별 토량환산계수. 유토곡선은 프론트가 이 값으로 계산한다. */
|
||||
earthwork_conversion: EarthworkConversion;
|
||||
/** 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다. */
|
||||
haul_equipment_limits?: HaulEquipmentLimit[];
|
||||
/**
|
||||
* 자연방토 판정 경사(rise/run). 성토측 자연 지반이 이보다 가파르면 흙이 스스로 흘러내려
|
||||
* 운반비를 세지 않는다. 못 받으면 프론트는 **자연방토 없음**으로 본다(보수적).
|
||||
*/
|
||||
natural_spoil_min_ground_slope?: number | null;
|
||||
}
|
||||
|
||||
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
|
||||
export interface SectionSummaryResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
longitudinal: Record<string, unknown> | null;
|
||||
length_m: number | null;
|
||||
cross_section_count: number;
|
||||
}
|
||||
|
||||
export interface SectionSample {
|
||||
chainage_m?: number;
|
||||
offset_m?: number;
|
||||
elevation_m?: number | null;
|
||||
z?: number | null;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface SectionStation {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
label: string;
|
||||
/** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */
|
||||
kind: "bp" | "ep" | "regular" | "irregular";
|
||||
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
|
||||
structure?: string;
|
||||
center_z: number | null;
|
||||
azimuth_deg: number | null;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
/** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */
|
||||
uphill_side?: "left" | "right" | null;
|
||||
frame: { left_xy: [number, number] };
|
||||
}
|
||||
|
||||
/** 계획선 샘플 (계획고와 지반고, 그 차이). */
|
||||
export interface DesignProfileSample {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
ground_elevation_m: number;
|
||||
difference_m: number;
|
||||
}
|
||||
|
||||
/** 절·성토 균형을 판정하는 구역 단위 결과. */
|
||||
export interface DesignProfileSegment {
|
||||
index: number;
|
||||
start_chainage_m: number;
|
||||
end_chainage_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
}
|
||||
|
||||
export interface DesignProfileSummary {
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
max_grade_pct: number;
|
||||
vertical_curve_count: number;
|
||||
pvi_count: number;
|
||||
balance_segment_count: number;
|
||||
balanced: boolean;
|
||||
main_direction: string;
|
||||
suggested_elevation_offset_m: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 계획선. 횡단 설계 기반 계획선이 추가될 수 있어 배열로 전달된다.
|
||||
*/
|
||||
export interface DesignProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
basis: string;
|
||||
samples: DesignProfileSample[];
|
||||
balance_segments: DesignProfileSegment[];
|
||||
summary: DesignProfileSummary;
|
||||
}
|
||||
|
||||
export interface LongitudinalSection {
|
||||
length_m: number;
|
||||
samples: SectionSample[];
|
||||
stations: SectionStation[];
|
||||
design_profiles?: DesignProfile[];
|
||||
/**
|
||||
* 계획선 변화점(PVI) 구조. B05가 편집 기준선으로 사용하며 `design_profiles`는
|
||||
* 여기서 파생된다. 구 데이터에는 없을 수 있어 optional이다.
|
||||
* 구조는 `B05_Profile_UI_Profile_Alignment.ProfileAlignment`.
|
||||
*/
|
||||
profile_alignment?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배수관 세트의 유입/유출 한쪽 부속 제원 (백엔드 `B06_Section_Engine_Culvert` 산출).
|
||||
* 구조가 "집수정"이면 기슭막이·보호공 필드가 없다 — 라벨만 쓴다(집수정 단면은 후속).
|
||||
*/
|
||||
export interface CulvertSideSpec {
|
||||
role: "inlet" | "outlet";
|
||||
structure: string;
|
||||
revet_form?: string | null;
|
||||
revet_height_m?: number | null;
|
||||
revet_length_m?: number | null;
|
||||
/** 기준측점 전/후 종방향 몫(m) — 3D 예상형상 배치(2026-08-23). 없으면 길이 절반씩. */
|
||||
revet_before_m?: number | null;
|
||||
revet_after_m?: number | null;
|
||||
/** 집수정 종방향 길이(m) — structure="집수정"일 때만. 기본 2m. */
|
||||
basin_length_m?: number | null;
|
||||
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
|
||||
basin_before_m?: number | null;
|
||||
basin_after_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
|
||||
face_slope?: number;
|
||||
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
|
||||
apron_length_m?: number;
|
||||
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
|
||||
apron_thickness_m?: number;
|
||||
}
|
||||
|
||||
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
|
||||
export interface CulvertSet {
|
||||
type: "pipe";
|
||||
pipe_kind: string | null;
|
||||
diameter_m: number;
|
||||
/** 관 위 최소 토피(m) — 별표2 교량·암거 복토 50㎝ 교차 참조. B05 하향 차단 기준. */
|
||||
min_cover_m: number;
|
||||
inlet: CulvertSideSpec;
|
||||
outlet: CulvertSideSpec;
|
||||
/** 독립 기슭막이(관 없는 벽) — true면 관을 그리지 않고 수량에서도 뺀다(2026-08-28). */
|
||||
hidden_pipe?: boolean;
|
||||
/** 독립 기슭막이 설치 측 — "양쪽" | "좌" | "우". 좌/우면 반대쪽 벽을 숨긴다. */
|
||||
side?: string;
|
||||
/** 독립 기슭막이 다단 요청 수(1이면 단일 벽). */
|
||||
tiers?: number;
|
||||
}
|
||||
|
||||
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
|
||||
export interface FordWingSpec {
|
||||
installed: boolean;
|
||||
height_m: number | null;
|
||||
length_m: number | null;
|
||||
angle_deg: number | null;
|
||||
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
|
||||
slab_extend_m: number;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
|
||||
export interface FordSet {
|
||||
type: "ford";
|
||||
pipe_kind: string | null;
|
||||
diameter_m: number;
|
||||
/** 관 련수. 단면엔 1개만 그리고 라벨에만 쓴다. */
|
||||
pipe_count: number;
|
||||
/** 구체의 도로 진행 방향 길이(m) = 월류 폭. 기준 측점 전후로 절반씩 걸친다. */
|
||||
span_m: number;
|
||||
/** 월류 높이(m) — 구체 위 노면이 이만큼 낮게 앉는다. 계획고를 통째로 내려 잡으므로
|
||||
* 측벽·바닥판·절성토 면적이 함께 따라간다(2026-08-30 사용자 확정). 0 = 내리지 않음. */
|
||||
overflow_depth_m: number;
|
||||
slab_thickness_m: number;
|
||||
wall_thickness_m: number;
|
||||
min_cover_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
}
|
||||
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
export interface BoxSet {
|
||||
type: "box";
|
||||
/** 사용자 입력 내공(유로) 폭·높이(m). */
|
||||
inner_width_m: number;
|
||||
inner_height_m: number;
|
||||
/** 부재 두께(m) — 세월교 승계(2026-08-25 사용자 확정). */
|
||||
wall_thickness_m: number;
|
||||
slab_thickness_m: number;
|
||||
top_thickness_m: number;
|
||||
/** 암거 위 복토(m) — 별표2 "복토 흙 두께 50㎝ 이상" 교차 참조. */
|
||||
cover_m: number;
|
||||
/** 도로 진행 방향 길이(m) = 내공 폭 + 측벽 2장. 기준 측점 전후로 절반씩 걸친다. */
|
||||
span_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
design?: CrossDesign;
|
||||
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
|
||||
culvert?: CulvertSet;
|
||||
/** 세월교 구체가 걸치는 측점의 세트 제원(있을 때만). 구체 폭 안이면 여러 측점에 붙는다. */
|
||||
ford?: FordSet;
|
||||
/** BOX암거 구체가 걸치는 측점의 세트 제원(있을 때만). */
|
||||
box?: BoxSet;
|
||||
/** 물넘이포장이 파는 노면 제원(있을 때만). 월류 폭 안의 측점 전부에 붙는다. */
|
||||
ford_pavement?: FordPavementSpec;
|
||||
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
|
||||
revetment?: RevetmentSpec;
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
longitudinal: LongitudinalSection;
|
||||
cross_sections: CrossSection[];
|
||||
/** 확정 시 저장해 둔 유토곡선 balloon 위치. 브라우저가 바뀌어도 같은 자리에 뜬다. */
|
||||
balloon_offsets?: BalloonOffsets | null;
|
||||
}
|
||||
|
||||
/** 종횡단 확정 결과 (SectionConfirmResponse) */
|
||||
export interface SectionConfirmResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계 지정값 (버튼 상태). */
|
||||
export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill";
|
||||
export type DitchSide = "left" | "right";
|
||||
export type DitchType = "standard" | "l_type";
|
||||
|
||||
/** 기슭막이 한 벽의 4축 조작값(좌우 x·상하 d·높이 h·형태 m). null = 자동. */
|
||||
export interface StoredWallAdjust {
|
||||
x: number;
|
||||
d: number | null;
|
||||
h: number | null;
|
||||
/** 형태 — B05 폼과 같은 목록(`REVET_FORMS`). 2026-08-29 이전 저장분은 재질
|
||||
* 코드("dry"/"wet"/"concrete")라 `revetFormOf`가 형태로 옮겨 읽는다. */
|
||||
m: string | null;
|
||||
}
|
||||
|
||||
/** 다단 기슭막이 한 단의 종방향 구간값(길이·기준측점 전/후 m — 2026-08-29). */
|
||||
export interface StoredWallSpan {
|
||||
length_m: number;
|
||||
before_m: number;
|
||||
after_m: number;
|
||||
}
|
||||
|
||||
/** 다단 기슭막이 단 수(유출 성토부 / 집수정 계류측). */
|
||||
export interface StoredExtraWallCounts {
|
||||
outlet: number;
|
||||
basin: number;
|
||||
}
|
||||
|
||||
/** 세월교 측벽 한 매의 저장형 조작값(2026-08-25). */
|
||||
export interface StoredFordWallAdjust {
|
||||
heightM: number | null;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 저장형 조작값 — 유입·유출 측벽을 따로 담는다. */
|
||||
export interface StoredFordAdjust {
|
||||
inlet: StoredFordWallAdjust;
|
||||
outlet: StoredFordWallAdjust;
|
||||
}
|
||||
|
||||
/** BOX암거 한쪽 끝의 저장형 조작값(2026-08-25). */
|
||||
export interface StoredBoxSideAdjust {
|
||||
lengthM: number;
|
||||
riseM: number;
|
||||
}
|
||||
|
||||
/** BOX암거 측점의 저장형 조작값 — 좌·우 끝을 따로 담는다. */
|
||||
export interface StoredBoxAdjust {
|
||||
left: StoredBoxSideAdjust;
|
||||
right: StoredBoxSideAdjust;
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
|
||||
export interface CrossDesign {
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
innerHeightM: number;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
/** 세월교 월류 높이만큼 계획고를 내려 잡은 양(m) — 이 값이 있으면 `design_elevation_m`·
|
||||
* `design_line`·절성토 면적이 **이미 내려간 값**이다. 프론트는 이 값으로 "월류가
|
||||
* 없었다면" 노면을 점선으로 되그린다(2026-08-30 사용자 확정). */
|
||||
surface_drop_m?: number;
|
||||
/** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */
|
||||
ford_adjust?: StoredFordAdjust;
|
||||
/** BOX암거 구체 조작값(좌·우 끝) — 길이·표고(2026-08-25). */
|
||||
box_adjust?: StoredBoxAdjust;
|
||||
/** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
* 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
/** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
/** 다단 기슭막이 **단별** 구간값 — 키는 벽 키("extra0"…/"bextra0"…). 기준벽 연장에
|
||||
* 종속되지 않고 단마다 따로 잡는다(2026-08-29 사용자). 없으면 기본 10m(5/5). */
|
||||
extra_spans?: Record<string, StoredWallSpan>;
|
||||
/** 연동 해제(측점별 — 2026-08-24 사용자). 옆 측점에서 연장돼 온 기슭막이의 위치
|
||||
* 4축을 이 측점에서 따로 잡는다. 구조물 추가가 아니라 3D 위치의 개별 지정이다. */
|
||||
revet_link_detached?: boolean;
|
||||
/** 종단경사 반영(소유 측점 1개 = 기슭막이 한 벌 전체 공통, 기본 켬).
|
||||
* 끄면 연장 구간의 표고를 소유 측점과 같게 본다. */
|
||||
revet_follow_grade?: boolean;
|
||||
ground_type: GroundType;
|
||||
geometry_preset: "soil" | "rock";
|
||||
section_mode: SectionMode;
|
||||
ditch_side: DitchSide;
|
||||
/** 측구 형식(일반/L형). 양성(측구 없음)은 null. */
|
||||
ditch_type: DitchType | null;
|
||||
cut_slope_ratio: number;
|
||||
/** 2단계 절토의 토사(상단) 경사비. 암 지반에서만 의미. */
|
||||
soil_cut_slope_ratio?: number;
|
||||
/** 암반 경계 기준 2단계 경사 적용 여부(엔진이 실제 적용했는지). */
|
||||
two_stage_slope?: boolean;
|
||||
fill_slope_ratio: number;
|
||||
roadbed_width_m: number;
|
||||
carriageway_width_m: number;
|
||||
cross_slope_pct: number;
|
||||
ditch:
|
||||
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
|
||||
| { type: "l_type"; width_m: number; depth_m: number }
|
||||
| { type: "none" };
|
||||
/** 측구 생성 여부(엔진이 자동/override 반영해 실제 적용한 결과). */
|
||||
ditch_enabled?: boolean;
|
||||
/** 포장 중첩 여부와 포장층 두께(포장 시). */
|
||||
paved: boolean;
|
||||
pavement_thickness_m?: number;
|
||||
/** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */
|
||||
pavement_suggested?: boolean;
|
||||
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
|
||||
road_edges: {
|
||||
left: { offset_m: number; elevation_m: number };
|
||||
right: { offset_m: number; elevation_m: number };
|
||||
};
|
||||
/** 차도(노견 제외) 양 끝점 — 포장 범위 기준. */
|
||||
carriageway_edges?: {
|
||||
left: { offset_m: number; elevation_m: number };
|
||||
right: { offset_m: number; elevation_m: number };
|
||||
};
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
/**
|
||||
* 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다.
|
||||
* 경계선을 올리내리면 두 값이 함께 바뀌고 유토곡선도 따라 움직인다.
|
||||
* 구 데이터에는 없으므로 optional — 없으면 `cut_area_m2` 전량을 지반유형으로 본다.
|
||||
*/
|
||||
cut_soil_area_m2?: number;
|
||||
cut_rock_area_m2?: number;
|
||||
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
|
||||
cut_rock_kind?: GroundType | null;
|
||||
fill_area_m2: number;
|
||||
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
|
||||
fill_ground_slope?: number | null;
|
||||
ditch_area_m2: number;
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
/** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */
|
||||
rock_boundary_offset_m?: number;
|
||||
/** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */
|
||||
display_half_width_m?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
design: CrossDesign;
|
||||
}
|
||||
|
||||
export interface CrossDesignRequest {
|
||||
chainage_m: number;
|
||||
ground_type: GroundType;
|
||||
section_mode: SectionMode;
|
||||
ditch_side?: DitchSide | null;
|
||||
/** 측구 형식(일반/L형). L형은 암 지반에서만 허용된다. */
|
||||
ditch_type?: DitchType;
|
||||
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
|
||||
paved?: boolean;
|
||||
/** 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산용. */
|
||||
rock_boundary_offset_m?: number | null;
|
||||
/** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
|
||||
two_stage_slope?: boolean;
|
||||
/** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */
|
||||
ditch_enabled?: boolean | null;
|
||||
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
|
||||
standard_cross_section?: StandardCrossSection;
|
||||
}
|
||||
|
||||
/** 확정 시 측점별 data.design에 병합할 프론트 세션 보관값. */
|
||||
export interface CrossSectionPatch {
|
||||
chainage_m: number;
|
||||
rock_boundary_offset_m?: number;
|
||||
/** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */
|
||||
display_half_width_m?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
innerHeightM: number;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
ford_adjust?: StoredFordAdjust;
|
||||
box_adjust?: StoredBoxAdjust;
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
extra_spans?: Record<string, StoredWallSpan>;
|
||||
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
|
||||
revet_link_detached?: boolean;
|
||||
revet_follow_grade?: boolean;
|
||||
}
|
||||
// 타입 정본은 `B06_Section_Api_Types.ts`. 기존 호출부가 여기서 가져오던 것을
|
||||
// 그대로 쓰도록 다시 내보낸다 — 이동은 파일만 나눈 것이고 계약은 그대로다.
|
||||
export type * from "./B06_Section_Api_Types";
|
||||
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_Api_Types.ts
|
||||
* 3차 워크플로우(종·횡단) API 응답·요청 **타입 정본**.
|
||||
* `B06_Section_Api_Fetch.ts`에서 잘라 냈다(700줄 제한, 2026-09-02) — 그 파일은
|
||||
* 704줄 중 486줄이 타입 선언이었다. 호출 코드는 종전처럼 `_Api_Fetch`에서 타입을
|
||||
* 가져와도 되고(그쪽이 그대로 다시 내보낸다) 이 파일에서 직접 가져와도 된다.
|
||||
* ========================================================================== */
|
||||
import type {
|
||||
BalloonOffsets,
|
||||
EarthworkConversion,
|
||||
GroundType,
|
||||
HaulEquipmentLimit,
|
||||
} from "@util/common_util_mass_haul_types";
|
||||
|
||||
export interface SectionOptionDefaults {
|
||||
station_interval_m: number;
|
||||
cross_half_width_m: number;
|
||||
cross_sample_interval_m: number;
|
||||
long_sample_interval_m: number;
|
||||
vertical_exaggeration: number;
|
||||
}
|
||||
|
||||
/** 측구 규격(상단폭/저폭/깊이, m). */
|
||||
export interface DitchSpec {
|
||||
top_width_m: number;
|
||||
bottom_width_m: number;
|
||||
depth_m: number;
|
||||
}
|
||||
|
||||
/** 지반그룹 하나의 표준 횡단면 기본값 (config STANDARD_CROSS_SECTION 사본). */
|
||||
export interface StandardCrossGroup {
|
||||
road_width_m: number;
|
||||
shoulder_left_m: number;
|
||||
shoulder_right_m: number;
|
||||
ditch: DitchSpec;
|
||||
/** 암 그룹만 존재: L형 측구(폭/깊이, m). */
|
||||
ditch_l_type?: { width_m: number; depth_m: number };
|
||||
cross_slope_pct: { min: number; max: number };
|
||||
fill_slope_ratio: number;
|
||||
cut_slope_ratio: number;
|
||||
/** 포장 그룹만 존재: 포장층 두께(m). */
|
||||
pavement_thickness_m?: number;
|
||||
}
|
||||
|
||||
/** 표준 횡단면 설정 패널 그룹 키. */
|
||||
export type StandardCrossKey = "soil" | "rock" | "paved";
|
||||
|
||||
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>;
|
||||
|
||||
// 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를
|
||||
// `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지).
|
||||
export type {
|
||||
GroundType,
|
||||
EarthworkConversionFactor,
|
||||
EarthworkConversion,
|
||||
HaulEquipmentLimit,
|
||||
BalloonOffsets,
|
||||
} from "@util/common_util_mass_haul_types";
|
||||
|
||||
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
|
||||
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
|
||||
export type { FordPavementSpec, RevetmentSpec };
|
||||
|
||||
export interface SectionContextResponse {
|
||||
project_id: string;
|
||||
route_id: number | null;
|
||||
filter_key: string | null;
|
||||
method: string | null;
|
||||
smooth: boolean | null;
|
||||
crs_epsg: number | null;
|
||||
/** 프로젝트 등록(B02)에서 정한 임도 종류 — B05 계획선 법정 기준의 출발점(2026-08-19). */
|
||||
road_type: string | null;
|
||||
defaults: SectionOptionDefaults;
|
||||
/** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */
|
||||
standard_cross_section: StandardCrossSection;
|
||||
/** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */
|
||||
rock_boundary_default_offset_m: number;
|
||||
rock_boundary_step_m: number;
|
||||
/** 지반유형별 토량환산계수. 유토곡선은 프론트가 이 값으로 계산한다. */
|
||||
earthwork_conversion: EarthworkConversion;
|
||||
/** 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다. */
|
||||
haul_equipment_limits?: HaulEquipmentLimit[];
|
||||
/**
|
||||
* 자연방토 판정 경사(rise/run). 성토측 자연 지반이 이보다 가파르면 흙이 스스로 흘러내려
|
||||
* 운반비를 세지 않는다. 못 받으면 프론트는 **자연방토 없음**으로 본다(보수적).
|
||||
*/
|
||||
natural_spoil_min_ground_slope?: number | null;
|
||||
}
|
||||
|
||||
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
|
||||
export interface SectionSummaryResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
longitudinal: Record<string, unknown> | null;
|
||||
length_m: number | null;
|
||||
cross_section_count: number;
|
||||
}
|
||||
|
||||
export interface SectionSample {
|
||||
chainage_m?: number;
|
||||
offset_m?: number;
|
||||
elevation_m?: number | null;
|
||||
z?: number | null;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface SectionStation {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
label: string;
|
||||
/** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */
|
||||
kind: "bp" | "ep" | "regular" | "irregular";
|
||||
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
|
||||
structure?: string;
|
||||
center_z: number | null;
|
||||
azimuth_deg: number | null;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
/** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */
|
||||
uphill_side?: "left" | "right" | null;
|
||||
frame: { left_xy: [number, number] };
|
||||
}
|
||||
|
||||
/** 계획선 샘플 (계획고와 지반고, 그 차이). */
|
||||
export interface DesignProfileSample {
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
ground_elevation_m: number;
|
||||
difference_m: number;
|
||||
}
|
||||
|
||||
/** 절·성토 균형을 판정하는 구역 단위 결과. */
|
||||
export interface DesignProfileSegment {
|
||||
index: number;
|
||||
start_chainage_m: number;
|
||||
end_chainage_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
}
|
||||
|
||||
export interface DesignProfileSummary {
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
balance_error_m2: number;
|
||||
max_grade_pct: number;
|
||||
vertical_curve_count: number;
|
||||
pvi_count: number;
|
||||
balance_segment_count: number;
|
||||
balanced: boolean;
|
||||
main_direction: string;
|
||||
suggested_elevation_offset_m: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 계획선. 횡단 설계 기반 계획선이 추가될 수 있어 배열로 전달된다.
|
||||
*/
|
||||
export interface DesignProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
basis: string;
|
||||
samples: DesignProfileSample[];
|
||||
balance_segments: DesignProfileSegment[];
|
||||
summary: DesignProfileSummary;
|
||||
}
|
||||
|
||||
export interface LongitudinalSection {
|
||||
length_m: number;
|
||||
samples: SectionSample[];
|
||||
stations: SectionStation[];
|
||||
design_profiles?: DesignProfile[];
|
||||
/**
|
||||
* 계획선 변화점(PVI) 구조. B05가 편집 기준선으로 사용하며 `design_profiles`는
|
||||
* 여기서 파생된다. 구 데이터에는 없을 수 있어 optional이다.
|
||||
* 구조는 `B05_Profile_UI_Profile_Alignment.ProfileAlignment`.
|
||||
*/
|
||||
profile_alignment?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 배수관 세트의 유입/유출 한쪽 부속 제원 (백엔드 `B06_Section_Engine_Culvert` 산출).
|
||||
* 구조가 "집수정"이면 기슭막이·보호공 필드가 없다 — 라벨만 쓴다(집수정 단면은 후속).
|
||||
*/
|
||||
export interface CulvertSideSpec {
|
||||
role: "inlet" | "outlet";
|
||||
structure: string;
|
||||
revet_form?: string | null;
|
||||
revet_height_m?: number | null;
|
||||
revet_length_m?: number | null;
|
||||
/** 기준측점 전/후 종방향 몫(m) — 3D 예상형상 배치(2026-08-23). 없으면 길이 절반씩. */
|
||||
revet_before_m?: number | null;
|
||||
revet_after_m?: number | null;
|
||||
/** 집수정 종방향 길이(m) — structure="집수정"일 때만. 기본 2m. */
|
||||
basin_length_m?: number | null;
|
||||
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
|
||||
basin_before_m?: number | null;
|
||||
basin_after_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
|
||||
face_slope?: number;
|
||||
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
|
||||
apron_length_m?: number;
|
||||
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
|
||||
apron_thickness_m?: number;
|
||||
}
|
||||
|
||||
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
|
||||
export interface CulvertSet {
|
||||
type: "pipe";
|
||||
pipe_kind: string | null;
|
||||
diameter_m: number;
|
||||
/** 관 위 최소 토피(m) — 별표2 교량·암거 복토 50㎝ 교차 참조. B05 하향 차단 기준. */
|
||||
min_cover_m: number;
|
||||
inlet: CulvertSideSpec;
|
||||
outlet: CulvertSideSpec;
|
||||
/** 독립 기슭막이(관 없는 벽) — true면 관을 그리지 않고 수량에서도 뺀다(2026-08-28). */
|
||||
hidden_pipe?: boolean;
|
||||
/** 독립 기슭막이 설치 측 — "양쪽" | "좌" | "우". 좌/우면 반대쪽 벽을 숨긴다. */
|
||||
side?: string;
|
||||
/** 독립 기슭막이 다단 요청 수(1이면 단일 벽). */
|
||||
tiers?: number;
|
||||
}
|
||||
|
||||
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
|
||||
export interface FordWingSpec {
|
||||
installed: boolean;
|
||||
height_m: number | null;
|
||||
length_m: number | null;
|
||||
angle_deg: number | null;
|
||||
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
|
||||
slab_extend_m: number;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
|
||||
export interface FordSet {
|
||||
type: "ford";
|
||||
pipe_kind: string | null;
|
||||
diameter_m: number;
|
||||
/** 관 련수. 단면엔 1개만 그리고 라벨에만 쓴다. */
|
||||
pipe_count: number;
|
||||
/** 구체의 도로 진행 방향 길이(m) = 월류 폭. 기준 측점 전후로 절반씩 걸친다. */
|
||||
span_m: number;
|
||||
/** 월류 높이(m) — 구체 위 노면이 이만큼 낮게 앉는다. 계획고를 통째로 내려 잡으므로
|
||||
* 측벽·바닥판·절성토 면적이 함께 따라간다(2026-08-30 사용자 확정). 0 = 내리지 않음. */
|
||||
overflow_depth_m: number;
|
||||
slab_thickness_m: number;
|
||||
wall_thickness_m: number;
|
||||
min_cover_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
}
|
||||
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
export interface BoxSet {
|
||||
type: "box";
|
||||
/** 사용자 입력 내공(유로) 폭·높이(m). */
|
||||
inner_width_m: number;
|
||||
inner_height_m: number;
|
||||
/** 부재 두께(m) — 세월교 승계(2026-08-25 사용자 확정). */
|
||||
wall_thickness_m: number;
|
||||
slab_thickness_m: number;
|
||||
top_thickness_m: number;
|
||||
/** 암거 위 복토(m) — 별표2 "복토 흙 두께 50㎝ 이상" 교차 참조. */
|
||||
cover_m: number;
|
||||
/** 도로 진행 방향 길이(m) = 내공 폭 + 측벽 2장. 기준 측점 전후로 절반씩 걸친다. */
|
||||
span_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
design?: CrossDesign;
|
||||
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
|
||||
culvert?: CulvertSet;
|
||||
/** 세월교 구체가 걸치는 측점의 세트 제원(있을 때만). 구체 폭 안이면 여러 측점에 붙는다. */
|
||||
ford?: FordSet;
|
||||
/** BOX암거 구체가 걸치는 측점의 세트 제원(있을 때만). */
|
||||
box?: BoxSet;
|
||||
/** 물넘이포장이 파는 노면 제원(있을 때만). 월류 폭 안의 측점 전부에 붙는다. */
|
||||
ford_pavement?: FordPavementSpec;
|
||||
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
|
||||
revetment?: RevetmentSpec;
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
longitudinal: LongitudinalSection;
|
||||
cross_sections: CrossSection[];
|
||||
/** 확정 시 저장해 둔 유토곡선 balloon 위치. 브라우저가 바뀌어도 같은 자리에 뜬다. */
|
||||
balloon_offsets?: BalloonOffsets | null;
|
||||
}
|
||||
|
||||
/** 종횡단 확정 결과 (SectionConfirmResponse) */
|
||||
export interface SectionConfirmResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계 지정값 (버튼 상태). */
|
||||
export type SectionMode = "left_cut" | "right_cut" | "both_cut" | "both_fill";
|
||||
export type DitchSide = "left" | "right";
|
||||
export type DitchType = "standard" | "l_type";
|
||||
|
||||
/** 기슭막이 한 벽의 4축 조작값(좌우 x·상하 d·높이 h·형태 m). null = 자동. */
|
||||
export interface StoredWallAdjust {
|
||||
x: number;
|
||||
d: number | null;
|
||||
h: number | null;
|
||||
/** 형태 — B05 폼과 같은 목록(`REVET_FORMS`). 2026-08-29 이전 저장분은 재질
|
||||
* 코드("dry"/"wet"/"concrete")라 `revetFormOf`가 형태로 옮겨 읽는다. */
|
||||
m: string | null;
|
||||
}
|
||||
|
||||
/** 다단 기슭막이 한 단의 종방향 구간값(길이·기준측점 전/후 m — 2026-08-29). */
|
||||
export interface StoredWallSpan {
|
||||
length_m: number;
|
||||
before_m: number;
|
||||
after_m: number;
|
||||
}
|
||||
|
||||
/** 다단 기슭막이 단 수(유출 성토부 / 집수정 계류측). */
|
||||
export interface StoredExtraWallCounts {
|
||||
outlet: number;
|
||||
basin: number;
|
||||
}
|
||||
|
||||
/** 세월교 측벽 한 매의 저장형 조작값(2026-08-25). */
|
||||
export interface StoredFordWallAdjust {
|
||||
heightM: number | null;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 저장형 조작값 — 유입·유출 측벽을 따로 담는다. */
|
||||
export interface StoredFordAdjust {
|
||||
inlet: StoredFordWallAdjust;
|
||||
outlet: StoredFordWallAdjust;
|
||||
}
|
||||
|
||||
/** BOX암거 한쪽 끝의 저장형 조작값(2026-08-25). */
|
||||
export interface StoredBoxSideAdjust {
|
||||
lengthM: number;
|
||||
riseM: number;
|
||||
}
|
||||
|
||||
/** BOX암거 측점의 저장형 조작값 — 좌·우 끝을 따로 담는다. */
|
||||
export interface StoredBoxAdjust {
|
||||
left: StoredBoxSideAdjust;
|
||||
right: StoredBoxSideAdjust;
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
|
||||
export interface CrossDesign {
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
innerHeightM: number;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
/** 세월교 월류 높이만큼 계획고를 내려 잡은 양(m) — 이 값이 있으면 `design_elevation_m`·
|
||||
* `design_line`·절성토 면적이 **이미 내려간 값**이다. 프론트는 이 값으로 "월류가
|
||||
* 없었다면" 노면을 점선으로 되그린다(2026-08-30 사용자 확정). */
|
||||
surface_drop_m?: number;
|
||||
/** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */
|
||||
ford_adjust?: StoredFordAdjust;
|
||||
/** BOX암거 구체 조작값(좌·우 끝) — 길이·표고(2026-08-25). */
|
||||
box_adjust?: StoredBoxAdjust;
|
||||
/** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
* 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
/** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
/** 다단 기슭막이 **단별** 구간값 — 키는 벽 키("extra0"…/"bextra0"…). 기준벽 연장에
|
||||
* 종속되지 않고 단마다 따로 잡는다(2026-08-29 사용자). 없으면 기본 10m(5/5). */
|
||||
extra_spans?: Record<string, StoredWallSpan>;
|
||||
/** 연동 해제(측점별 — 2026-08-24 사용자). 옆 측점에서 연장돼 온 기슭막이의 위치
|
||||
* 4축을 이 측점에서 따로 잡는다. 구조물 추가가 아니라 3D 위치의 개별 지정이다. */
|
||||
revet_link_detached?: boolean;
|
||||
/** 종단경사 반영(소유 측점 1개 = 기슭막이 한 벌 전체 공통, 기본 켬).
|
||||
* 끄면 연장 구간의 표고를 소유 측점과 같게 본다. */
|
||||
revet_follow_grade?: boolean;
|
||||
ground_type: GroundType;
|
||||
geometry_preset: "soil" | "rock";
|
||||
section_mode: SectionMode;
|
||||
ditch_side: DitchSide;
|
||||
/** 측구 형식(일반/L형). 양성(측구 없음)은 null. */
|
||||
ditch_type: DitchType | null;
|
||||
cut_slope_ratio: number;
|
||||
/** 2단계 절토의 토사(상단) 경사비. 암 지반에서만 의미. */
|
||||
soil_cut_slope_ratio?: number;
|
||||
/** 암반 경계 기준 2단계 경사 적용 여부(엔진이 실제 적용했는지). */
|
||||
two_stage_slope?: boolean;
|
||||
fill_slope_ratio: number;
|
||||
roadbed_width_m: number;
|
||||
carriageway_width_m: number;
|
||||
cross_slope_pct: number;
|
||||
ditch:
|
||||
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
|
||||
| { type: "l_type"; width_m: number; depth_m: number }
|
||||
| { type: "none" };
|
||||
/** 측구 생성 여부(엔진이 자동/override 반영해 실제 적용한 결과). */
|
||||
ditch_enabled?: boolean;
|
||||
/** 포장 중첩 여부와 포장층 두께(포장 시). */
|
||||
paved: boolean;
|
||||
pavement_thickness_m?: number;
|
||||
/** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */
|
||||
pavement_suggested?: boolean;
|
||||
/** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */
|
||||
road_edges: {
|
||||
left: { offset_m: number; elevation_m: number };
|
||||
right: { offset_m: number; elevation_m: number };
|
||||
};
|
||||
/** 차도(노견 제외) 양 끝점 — 포장 범위 기준. */
|
||||
carriageway_edges?: {
|
||||
left: { offset_m: number; elevation_m: number };
|
||||
right: { offset_m: number; elevation_m: number };
|
||||
};
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
/**
|
||||
* 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다.
|
||||
* 경계선을 올리내리면 두 값이 함께 바뀌고 유토곡선도 따라 움직인다.
|
||||
* 구 데이터에는 없으므로 optional — 없으면 `cut_area_m2` 전량을 지반유형으로 본다.
|
||||
*/
|
||||
cut_soil_area_m2?: number;
|
||||
cut_rock_area_m2?: number;
|
||||
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
|
||||
cut_rock_kind?: GroundType | null;
|
||||
fill_area_m2: number;
|
||||
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
|
||||
fill_ground_slope?: number | null;
|
||||
ditch_area_m2: number;
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
/** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */
|
||||
rock_boundary_offset_m?: number;
|
||||
/** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */
|
||||
display_half_width_m?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
design: CrossDesign;
|
||||
}
|
||||
|
||||
export interface CrossDesignRequest {
|
||||
chainage_m: number;
|
||||
ground_type: GroundType;
|
||||
section_mode: SectionMode;
|
||||
ditch_side?: DitchSide | null;
|
||||
/** 측구 형식(일반/L형). L형은 암 지반에서만 허용된다. */
|
||||
ditch_type?: DitchType;
|
||||
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
|
||||
paved?: boolean;
|
||||
/** 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산용. */
|
||||
rock_boundary_offset_m?: number | null;
|
||||
/** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
|
||||
two_stage_slope?: boolean;
|
||||
/** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */
|
||||
ditch_enabled?: boolean | null;
|
||||
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
|
||||
standard_cross_section?: StandardCrossSection;
|
||||
}
|
||||
|
||||
/** 확정 시 측점별 data.design에 병합할 프론트 세션 보관값. */
|
||||
export interface CrossSectionPatch {
|
||||
chainage_m: number;
|
||||
rock_boundary_offset_m?: number;
|
||||
/** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */
|
||||
display_half_width_m?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
innerHeightM: number;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
ford_adjust?: StoredFordAdjust;
|
||||
box_adjust?: StoredBoxAdjust;
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
extra_spans?: Record<string, StoredWallSpan>;
|
||||
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
|
||||
revet_link_detached?: boolean;
|
||||
revet_follow_grade?: boolean;
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
@@ -19,9 +13,6 @@ import {
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
computeCrossDesign,
|
||||
confirmSections,
|
||||
saveSections,
|
||||
type CrossSectionPatch,
|
||||
fetchSectionContext,
|
||||
getSections,
|
||||
previewCrossDesigns,
|
||||
@@ -29,22 +20,22 @@ import {
|
||||
type SectionDetailResponse,
|
||||
type StandardCrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { buildCrossPatches } from "./B06_Section_UI_Page_Patches";
|
||||
import {
|
||||
confirmCurrentSections,
|
||||
createRockBoundaryStore,
|
||||
saveCurrentSections,
|
||||
type SectionPersistContext,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import {
|
||||
type CrossDesignChange,
|
||||
createSectionView,
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Section_View";
|
||||
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
type PipeOptionsContext,
|
||||
} from "./B06_Section_UI_Page_Pipe_Options";
|
||||
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createB06StructuresPanel,
|
||||
@@ -111,14 +102,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
const saveButton = createButton({
|
||||
label: L("B06_Profile_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void saveCurrentSections(),
|
||||
onClick: () => void saveCurrentSections(persistContext),
|
||||
});
|
||||
saveButton.title = L("B06_Profile_Btn_Save_Tip");
|
||||
saveButton.disabled = true;
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentSections(),
|
||||
onClick: () => void confirmCurrentSections(persistContext),
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
@@ -177,113 +168,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationInterval: () => stationInterval ?? 20,
|
||||
focusChainage: focusStationAt,
|
||||
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
|
||||
applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(chainageM, patch),
|
||||
applyPipeOptions: (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
movePipe: (fromChainageM, toChainageM) =>
|
||||
stationControls.queueCulvertMove(fromChainageM, toChainageM),
|
||||
});
|
||||
|
||||
/**
|
||||
* 폼에서 바꾼 관 옵션을 **횡단 캐시**(`section.culvert` 스펙)에 얹고, 그 구조물이
|
||||
* 덮는 측점 카드를 다시 그린다 — 조정창 조작과 같은 흐름이라 도면이 바로 따라온다
|
||||
* (2026-08-29 사용자 보고: 값만 바뀌고 횡단도가 그대로였다).
|
||||
* 여기서는 화면 캐시만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
|
||||
*/
|
||||
function applyPipeOptionsToCache(
|
||||
chainageM: number,
|
||||
patch: Record<string, number | string>,
|
||||
): void {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
if (!owner) return;
|
||||
// 세월교는 스펙 자리(`section.ford`)가 배수관과 달라 조정창 제어기로 보낸다 —
|
||||
// 로직은 그쪽 것을 쓰고 프론트만 폼 UI다(2026-08-30 사용자 확정).
|
||||
if (owner.ford) {
|
||||
applyFordFormOptions(stationControls.ford, owner.chainage_m, patch);
|
||||
return;
|
||||
}
|
||||
// BOX암거도 스펙 자리가 따로다(`section.box`) — 같은 규칙으로 제어기에 보낸다
|
||||
// (2026-08-30 사용자: 세월교와 같은 문제).
|
||||
if (owner.box) {
|
||||
applyBoxFormOptions(stationControls.box, owner.chainage_m, patch);
|
||||
return;
|
||||
}
|
||||
const culvert = owner.culvert;
|
||||
if (!culvert) return;
|
||||
const num = (key: string): number | undefined => {
|
||||
const value = Number(patch[key]);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const put = <T extends object>(target: T, field: keyof T, value: unknown): void => {
|
||||
if (value !== undefined) (target as Record<string, unknown>)[field as string] = value;
|
||||
};
|
||||
put(culvert, "pipe_kind", patch.pipe_kind);
|
||||
const diameterMm = num("pipe_diameter_mm");
|
||||
if (diameterMm !== undefined) culvert.diameter_m = diameterMm / 1000;
|
||||
// 유입·유출 기슭막이 제원과 집수정 구간값 — 폼 옵션 키를 스펙 자리로 옮긴다.
|
||||
for (const [side, prefix] of [
|
||||
[culvert.inlet, "inlet"],
|
||||
[culvert.outlet, "outlet"],
|
||||
] as const) {
|
||||
put(side, "revet_form", patch[`${prefix}_revet_form`]);
|
||||
put(side, "revet_height_m", num(`${prefix}_revet_height_m`));
|
||||
put(side, "revet_length_m", num(`${prefix}_revet_length_m`));
|
||||
put(side, "revet_before_m", num(`${prefix}_revet_before_m`));
|
||||
put(side, "revet_after_m", num(`${prefix}_revet_after_m`));
|
||||
put(side, "structure", patch[`${prefix}_type`]);
|
||||
}
|
||||
// 배관 벽의 **높이·형태는 스펙이 아니라 조정값**(revet_adjust.h·m)이 정한다
|
||||
// — `revetWallSpec`이 배관에서는 spec.revet_height_m을 쓰지 않기 때문이다.
|
||||
// 폼에서 고친 값을 조정창과 같은 채널로 보내야 도면이 따라온다(2026-08-29 사용자).
|
||||
for (const [role, prefix] of [
|
||||
["inlet", "inlet"],
|
||||
["outlet", "outlet"],
|
||||
] as const) {
|
||||
const height = num(`${prefix}_revet_height_m`);
|
||||
const form = patch[`${prefix}_revet_form`];
|
||||
if (height === undefined && typeof form !== "string") continue;
|
||||
stationControls.revetOffset.update(owner.chainage_m, role, {
|
||||
...(height !== undefined ? { h: height } : {}),
|
||||
...(typeof form === "string" ? { m: form } : {}),
|
||||
});
|
||||
// 형태마다 높이 한계가 있다(돌쌓기(메) 2.0m 등). 기하가 잘라 낸 실제 높이를
|
||||
// 폼에 되돌린다 — 숫자만 커지고 그림은 그대로인 상태를 남기지 않는다.
|
||||
if (height === undefined) continue;
|
||||
const applied = revetWallSpec(
|
||||
role === "outlet" ? culvert.outlet : culvert.inlet,
|
||||
stationControls.revetOffset.adjustFor(owner, role),
|
||||
culvert.hidden_pipe === true,
|
||||
culvert.diameter_m,
|
||||
).pureHeight;
|
||||
if (Math.abs(applied - height) > 0.05) {
|
||||
structuresPanel.overrideOptions(owner.chainage_m, {
|
||||
[`${prefix}_revet_height_m`]: Number(applied.toFixed(1)),
|
||||
});
|
||||
showToast(
|
||||
`${prefix === "outlet" ? "유출" : "유입"} 기슭막이 높이는 형태 한계로 ` +
|
||||
`${applied.toFixed(1)}m까지만 적용됩니다.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
put(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
|
||||
put(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
|
||||
put(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
|
||||
// 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다.
|
||||
const reach = Math.max(
|
||||
culvert.inlet.revet_before_m ?? 0,
|
||||
culvert.inlet.revet_after_m ?? 0,
|
||||
culvert.outlet.revet_before_m ?? 0,
|
||||
culvert.outlet.revet_after_m ?? 0,
|
||||
num("inlet_revet_length_m") ?? 0,
|
||||
num("outlet_revet_length_m") ?? 0,
|
||||
);
|
||||
for (const other of sectionDetail?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
sectionView.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dockDivider = document.createElement("hr");
|
||||
dockDivider.className = "b05-structure__divider";
|
||||
const actionDock = document.createElement("div");
|
||||
@@ -506,80 +396,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 암 경계선 오프셋(측점별) 세션 저장소 ─────────────────────────────
|
||||
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
|
||||
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
|
||||
* 기본 오프셋·스텝은 context(config) 값으로 갱신된다. */
|
||||
let rockBoundaryDefault = -0.5;
|
||||
let rockBoundaryStep = 0.1;
|
||||
/**
|
||||
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
|
||||
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
|
||||
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
|
||||
*/
|
||||
const clampRockOffset = (value: number): number => Math.min(value, 0);
|
||||
const rockOffsets = new Map<string, number>();
|
||||
const rockKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
const rockSessionKey = (): string | null =>
|
||||
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null;
|
||||
|
||||
function loadRockOffsets(): void {
|
||||
rockOffsets.clear();
|
||||
const key = rockSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, offset]) => {
|
||||
if (Number.isFinite(offset)) rockOffsets.set(chainage, offset);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistRockOffsets(): void {
|
||||
const key = rockSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(rockOffsets)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
const rockBoundaryControl: RockBoundaryControl = {
|
||||
get stepM() {
|
||||
return rockBoundaryStep;
|
||||
},
|
||||
get defaultOffsetM() {
|
||||
return rockBoundaryDefault;
|
||||
},
|
||||
offsetFor: (section) =>
|
||||
clampRockOffset(
|
||||
rockOffsets.get(rockKey(section.chainage_m)) ??
|
||||
section.design?.rock_boundary_offset_m ??
|
||||
rockBoundaryDefault,
|
||||
),
|
||||
adjust: (chainageM, deltaM) => {
|
||||
const key = rockKey(chainageM);
|
||||
const stored = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
||||
)?.design?.rock_boundary_offset_m;
|
||||
const current = clampRockOffset(rockOffsets.get(key) ?? stored ?? rockBoundaryDefault);
|
||||
rockOffsets.set(key, clampRockOffset(Math.round((current + deltaM) * 100) / 100));
|
||||
persistRockOffsets();
|
||||
sectionView.refreshCard(chainageM);
|
||||
recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산
|
||||
},
|
||||
reset: (chainageM) => {
|
||||
rockOffsets.set(rockKey(chainageM), rockBoundaryDefault);
|
||||
persistRockOffsets();
|
||||
sectionView.refreshCard(chainageM);
|
||||
recomputeIfRock(chainageM);
|
||||
},
|
||||
};
|
||||
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
|
||||
const rockStore = createRockBoundaryStore({
|
||||
sessionKey: () =>
|
||||
projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null,
|
||||
detail: () => sectionDetail,
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
recompute: (chainageM) => recomputeIfRock(chainageM),
|
||||
});
|
||||
const rockOffsets = rockStore.offsets;
|
||||
const rockBoundaryControl = rockStore.control;
|
||||
|
||||
const stationControls = createStationControls({
|
||||
sessionKey: (kind) =>
|
||||
@@ -609,6 +435,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls.ford,
|
||||
stationControls.box,
|
||||
);
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
detail: () => sectionDetail,
|
||||
ford: stationControls.ford,
|
||||
box: stationControls.box,
|
||||
revetOffset: stationControls.revetOffset,
|
||||
overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
};
|
||||
// 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화).
|
||||
const structureSelection = wireStructureSelection(
|
||||
stationControls,
|
||||
@@ -707,12 +542,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
|
||||
function collectSectionEdits(): {
|
||||
crossPatches: CrossSectionPatch[];
|
||||
massHaul: Record<string, unknown> | undefined;
|
||||
} {
|
||||
const crossPatches = buildCrossPatches({
|
||||
// [저장]·[확정]과 편집분 수집은 저장 흐름 모듈에 있다(2026-09-02 분리).
|
||||
const persistContext: SectionPersistContext = {
|
||||
projectId,
|
||||
routeId: () => currentRouteId,
|
||||
detail: () => sectionDetail,
|
||||
context: () => context,
|
||||
standardValues: () => standardPanel?.getValues(),
|
||||
flushCulvertOptions: () => stationControls.flushCulvertOptions(),
|
||||
patchSources: () => ({
|
||||
rockOffsets,
|
||||
stationWidths,
|
||||
inletStructures,
|
||||
@@ -723,91 +561,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
fordAdjusts: stationControls.fordAdjustsByChainage(),
|
||||
boxAdjusts: stationControls.boxAdjustsByChainage(),
|
||||
linkFlags: stationControls.linkFlagsByChainage(),
|
||||
});
|
||||
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
|
||||
const result =
|
||||
sectionDetail && context?.earthwork_conversion
|
||||
? computeMassHaul(
|
||||
sectionDetail.cross_sections,
|
||||
context.earthwork_conversion,
|
||||
context.natural_spoil_min_ground_slope ?? undefined,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
crossPatches,
|
||||
massHaul: result
|
||||
? massHaulPayload(
|
||||
result,
|
||||
computeHaulPlan(result, context?.haul_equipment_limits),
|
||||
balloonOffsetsPayload(),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
|
||||
async function saveCurrentSections(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await stationControls.flushCulvertOptions();
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||
await flushPendingStructures(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
const edits = collectSectionEdits();
|
||||
await saveSections(
|
||||
projectId,
|
||||
currentRouteId,
|
||||
standardPanel?.getValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCurrentSections(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await stationControls.flushCulvertOptions();
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||
await flushPendingStructures(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
const edits = collectSectionEdits();
|
||||
await confirmSections(
|
||||
projectId,
|
||||
currentRouteId,
|
||||
standardPanel?.getValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
let workflowState: WorkflowState | undefined;
|
||||
let context: SectionContextResponse | null = null;
|
||||
@@ -850,8 +605,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// (2026-08-19 사용자 지시). 사용자 확정 이력·세션값이 있으면 아래에서 덮는다.
|
||||
crossHalfWidthField.input.value = String(DISPLAY_HALF_WIDTH_DEFAULT_M);
|
||||
stationInterval = context.defaults.station_interval_m;
|
||||
rockBoundaryDefault = context.rock_boundary_default_offset_m;
|
||||
rockBoundaryStep = context.rock_boundary_step_m;
|
||||
rockStore.setDefaults(context.rock_boundary_default_offset_m, context.rock_boundary_step_m);
|
||||
|
||||
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
|
||||
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
|
||||
@@ -865,7 +619,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
currentRouteId = context.route_id;
|
||||
loadRockOffsets();
|
||||
rockStore.load();
|
||||
stationControls.load();
|
||||
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
|
||||
void structuresPanel.load();
|
||||
|
||||
@@ -3,8 +3,60 @@
|
||||
* 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기
|
||||
* (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
|
||||
* 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다.
|
||||
*
|
||||
* 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) —
|
||||
* 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목
|
||||
* 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 세션에 담기는 측점별 조작값 한 겹 맵. */
|
||||
export interface SessionMap<V> {
|
||||
values: Map<string, V>;
|
||||
/** 세션값 읽기 — 맵을 비우고 다시 채운다. */
|
||||
load: () => void;
|
||||
/** 맵을 통째로 세션에 쓴다. */
|
||||
persist: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고,
|
||||
* `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리).
|
||||
*/
|
||||
export function createSessionMap<V>(
|
||||
sessionKey: () => string | null,
|
||||
accept: (value: unknown) => V | undefined,
|
||||
): SessionMap<V> {
|
||||
const values = new Map<string, V>();
|
||||
return {
|
||||
values,
|
||||
load(): void {
|
||||
values.clear();
|
||||
const key = sessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
Object.entries(parsed).forEach(([entry, value]) => {
|
||||
const taken = accept(value);
|
||||
if (taken !== undefined) values.set(entry, taken);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */
|
||||
}
|
||||
},
|
||||
persist(): void {
|
||||
const key = sessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(values)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinkFlagMaps {
|
||||
detached: Map<string, boolean>;
|
||||
followGrade: Map<string, boolean>;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Persist.ts
|
||||
* B06 페이지의 **저장 흐름** — 암 경계선 세션 저장소, 편집분 수집, [저장]·[확정].
|
||||
*
|
||||
* `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 화면 조립·조정창
|
||||
* 배선은 페이지에 남고, 여기에는 "세션에 쌓인 조작을 정본으로 내보내는" 경로만 둔다
|
||||
* (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
confirmSections,
|
||||
saveSections,
|
||||
type CrossSectionPatch,
|
||||
type SectionContextResponse,
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
|
||||
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
|
||||
export interface RockBoundaryStore {
|
||||
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
|
||||
offsets: Map<string, number>;
|
||||
control: RockBoundaryControl;
|
||||
/** 세션값 읽기 — 프로젝트·노선이 정해진 뒤에 부른다. */
|
||||
load: () => void;
|
||||
/** context(config) 기본값·스텝 반영. */
|
||||
setDefaults: (defaultOffsetM: number, stepM: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 암 경계선 오프셋(측점별) 세션 저장소.
|
||||
*
|
||||
* 서버 재계산 없이 프론트 세션(sessionStorage)에 보관하고, 종횡단 확정 시
|
||||
* cross_patches로 DB(data.design.rock_boundary_offset_m)에 병합한다.
|
||||
*/
|
||||
export function createRockBoundaryStore(options: {
|
||||
sessionKey: () => string | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 경계 이동 → 2단계 무릎·단면적 재계산. */
|
||||
recompute: (chainageM: number) => void;
|
||||
}): RockBoundaryStore {
|
||||
const { sessionKey, detail, refreshCard, recompute } = options;
|
||||
let rockBoundaryDefault = -0.5;
|
||||
let rockBoundaryStep = 0.1;
|
||||
/**
|
||||
* 암 경계선 오프셋은 **0을 넘을 수 없다**(2026-08-02 사용자 지시). 오프셋은 지면선에서
|
||||
* 아래로 파고든 깊이라, 양수가 되면 경계선이 지표면 위로 떠올라 토사층이 음수가 된다.
|
||||
* DB에 옛 양수값이 남아 있어도 읽는 즉시 0으로 눌러 계산이 뒤집히지 않게 한다.
|
||||
*/
|
||||
const clamp = (value: number): number => Math.min(value, 0);
|
||||
const offsets = new Map<string, number>();
|
||||
const key = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
function persist(): void {
|
||||
const storageKey = sessionKey();
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(offsets)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offsets,
|
||||
load(): void {
|
||||
offsets.clear();
|
||||
const storageKey = sessionKey();
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, offset]) => {
|
||||
if (Number.isFinite(offset)) offsets.set(chainage, offset);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 기본값으로 재시작. */
|
||||
}
|
||||
},
|
||||
setDefaults(defaultOffsetM: number, stepM: number): void {
|
||||
rockBoundaryDefault = defaultOffsetM;
|
||||
rockBoundaryStep = stepM;
|
||||
},
|
||||
control: {
|
||||
get stepM() {
|
||||
return rockBoundaryStep;
|
||||
},
|
||||
get defaultOffsetM() {
|
||||
return rockBoundaryDefault;
|
||||
},
|
||||
offsetFor: (section) =>
|
||||
clamp(
|
||||
offsets.get(key(section.chainage_m)) ??
|
||||
section.design?.rock_boundary_offset_m ??
|
||||
rockBoundaryDefault,
|
||||
),
|
||||
adjust: (chainageM, deltaM) => {
|
||||
const stored = detail()?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
||||
)?.design?.rock_boundary_offset_m;
|
||||
const current = clamp(offsets.get(key(chainageM)) ?? stored ?? rockBoundaryDefault);
|
||||
offsets.set(key(chainageM), clamp(Math.round((current + deltaM) * 100) / 100));
|
||||
persist();
|
||||
refreshCard(chainageM);
|
||||
recompute(chainageM);
|
||||
},
|
||||
reset: (chainageM) => {
|
||||
offsets.set(key(chainageM), rockBoundaryDefault);
|
||||
persist();
|
||||
refreshCard(chainageM);
|
||||
recompute(chainageM);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */
|
||||
export interface SectionPersistContext {
|
||||
projectId: string | null;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
context: () => SectionContextResponse | null;
|
||||
standardValues: () => StandardCrossSection | undefined;
|
||||
/** 조정창 구간값을 정본으로 내보낸다(세션 → 서버). */
|
||||
flushCulvertOptions: () => Promise<void>;
|
||||
/** 측점별 편집분의 출처 묶음 — 세션·제어기에 흩어진 값을 페이지가 모아 준다. */
|
||||
patchSources: () => CrossPatchSources;
|
||||
}
|
||||
|
||||
/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */
|
||||
export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
crossPatches: CrossSectionPatch[];
|
||||
massHaul: Record<string, unknown> | undefined;
|
||||
} {
|
||||
const crossPatches = buildCrossPatches(ctx.patchSources());
|
||||
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
|
||||
const detail = ctx.detail();
|
||||
const context = ctx.context();
|
||||
const result =
|
||||
detail && context?.earthwork_conversion
|
||||
? computeMassHaul(
|
||||
detail.cross_sections,
|
||||
context.earthwork_conversion,
|
||||
context.natural_spoil_min_ground_slope ?? undefined,
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
crossPatches,
|
||||
massHaul: result
|
||||
? massHaulPayload(
|
||||
result,
|
||||
computeHaulPlan(result, context?.haul_equipment_limits),
|
||||
balloonOffsetsPayload(),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
|
||||
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
|
||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await ctx.flushCulvertOptions();
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||
await flushPendingStructures(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
}
|
||||
|
||||
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
|
||||
export async function saveCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
const routeId = ctx.routeId();
|
||||
if (!ctx.projectId || routeId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await flushPendingEdits(ctx, ctx.projectId);
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await saveSections(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */
|
||||
export async function confirmCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
const routeId = ctx.routeId();
|
||||
if (!ctx.projectId || routeId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await flushPendingEdits(ctx, ctx.projectId);
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await confirmSections(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Pipe_Options.ts
|
||||
* 「구조물 배치」 폼에서 바꾼 **관 옵션을 횡단 캐시에 얹는** 경로.
|
||||
*
|
||||
* `B06_Section_UI_Page` 에서 떼어낸 몫이다(700줄 제한, 2026-09-02). 여기서는 화면
|
||||
* 캐시(`section.culvert`·조정창 제어기)만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
|
||||
type StationControls = ReturnType<typeof createStationControls>;
|
||||
|
||||
/** 폼 → 캐시 반영에 필요한 페이지 창구만 모은 것. */
|
||||
export interface PipeOptionsContext {
|
||||
detail: () => SectionDetailResponse | null;
|
||||
ford: StationControls["ford"];
|
||||
box: StationControls["box"];
|
||||
revetOffset: StationControls["revetOffset"];
|
||||
/** 기하가 잘라 낸 실제 값을 폼에 되돌린다. */
|
||||
overrideOptions: (chainageM: number, values: Record<string, number | string>) => void;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼에서 바꾼 관 옵션을 **횡단 캐시**(`section.culvert` 스펙)에 얹고, 그 구조물이
|
||||
* 덮는 측점 카드를 다시 그린다 — 조정창 조작과 같은 흐름이라 도면이 바로 따라온다
|
||||
* (2026-08-29 사용자 보고: 값만 바뀌고 횡단도가 그대로였다).
|
||||
* 여기서는 화면 캐시만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
|
||||
*/
|
||||
export function applyPipeOptionsToCache(
|
||||
ctx: PipeOptionsContext,
|
||||
chainageM: number,
|
||||
patch: Record<string, number | string>,
|
||||
): void {
|
||||
const owner = ctx
|
||||
.detail()
|
||||
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.51);
|
||||
if (!owner) return;
|
||||
// 세월교는 스펙 자리(`section.ford`)가 배수관과 달라 조정창 제어기로 보낸다 —
|
||||
// 로직은 그쪽 것을 쓰고 프론트만 폼 UI다(2026-08-30 사용자 확정).
|
||||
if (owner.ford) {
|
||||
applyFordFormOptions(ctx.ford, owner.chainage_m, patch);
|
||||
return;
|
||||
}
|
||||
// BOX암거도 스펙 자리가 따로다(`section.box`) — 같은 규칙으로 제어기에 보낸다
|
||||
// (2026-08-30 사용자: 세월교와 같은 문제).
|
||||
if (owner.box) {
|
||||
applyBoxFormOptions(ctx.box, owner.chainage_m, patch);
|
||||
return;
|
||||
}
|
||||
const culvert = owner.culvert;
|
||||
if (!culvert) return;
|
||||
const num = (key: string): number | undefined => {
|
||||
const value = Number(patch[key]);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const put = <T extends object>(target: T, field: keyof T, value: unknown): void => {
|
||||
if (value !== undefined) (target as Record<string, unknown>)[field as string] = value;
|
||||
};
|
||||
put(culvert, "pipe_kind", patch.pipe_kind);
|
||||
const diameterMm = num("pipe_diameter_mm");
|
||||
if (diameterMm !== undefined) culvert.diameter_m = diameterMm / 1000;
|
||||
// 유입·유출 기슭막이 제원과 집수정 구간값 — 폼 옵션 키를 스펙 자리로 옮긴다.
|
||||
for (const [side, prefix] of [
|
||||
[culvert.inlet, "inlet"],
|
||||
[culvert.outlet, "outlet"],
|
||||
] as const) {
|
||||
put(side, "revet_form", patch[`${prefix}_revet_form`]);
|
||||
put(side, "revet_height_m", num(`${prefix}_revet_height_m`));
|
||||
put(side, "revet_length_m", num(`${prefix}_revet_length_m`));
|
||||
put(side, "revet_before_m", num(`${prefix}_revet_before_m`));
|
||||
put(side, "revet_after_m", num(`${prefix}_revet_after_m`));
|
||||
put(side, "structure", patch[`${prefix}_type`]);
|
||||
}
|
||||
// 배관 벽의 **높이·형태는 스펙이 아니라 조정값**(revet_adjust.h·m)이 정한다
|
||||
// — `revetWallSpec`이 배관에서는 spec.revet_height_m을 쓰지 않기 때문이다.
|
||||
// 폼에서 고친 값을 조정창과 같은 채널로 보내야 도면이 따라온다(2026-08-29 사용자).
|
||||
for (const [role, prefix] of [
|
||||
["inlet", "inlet"],
|
||||
["outlet", "outlet"],
|
||||
] as const) {
|
||||
const height = num(`${prefix}_revet_height_m`);
|
||||
const form = patch[`${prefix}_revet_form`];
|
||||
if (height === undefined && typeof form !== "string") continue;
|
||||
ctx.revetOffset.update(owner.chainage_m, role, {
|
||||
...(height !== undefined ? { h: height } : {}),
|
||||
...(typeof form === "string" ? { m: form } : {}),
|
||||
});
|
||||
// 형태마다 높이 한계가 있다(돌쌓기(메) 2.0m 등). 기하가 잘라 낸 실제 높이를
|
||||
// 폼에 되돌린다 — 숫자만 커지고 그림은 그대로인 상태를 남기지 않는다.
|
||||
if (height === undefined) continue;
|
||||
const applied = revetWallSpec(
|
||||
role === "outlet" ? culvert.outlet : culvert.inlet,
|
||||
ctx.revetOffset.adjustFor(owner, role),
|
||||
culvert.hidden_pipe === true,
|
||||
culvert.diameter_m,
|
||||
).pureHeight;
|
||||
if (Math.abs(applied - height) > 0.05) {
|
||||
ctx.overrideOptions(owner.chainage_m, {
|
||||
[`${prefix}_revet_height_m`]: Number(applied.toFixed(1)),
|
||||
});
|
||||
showToast(
|
||||
`${prefix === "outlet" ? "유출" : "유입"} 기슭막이 높이는 형태 한계로 ` +
|
||||
`${applied.toFixed(1)}m까지만 적용됩니다.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
put(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
|
||||
put(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
|
||||
put(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
|
||||
// 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다.
|
||||
const reach = Math.max(
|
||||
culvert.inlet.revet_before_m ?? 0,
|
||||
culvert.inlet.revet_after_m ?? 0,
|
||||
culvert.outlet.revet_before_m ?? 0,
|
||||
culvert.outlet.revet_after_m ?? 0,
|
||||
num("inlet_revet_length_m") ?? 0,
|
||||
num("outlet_revet_length_m") ?? 0,
|
||||
);
|
||||
for (const other of ctx.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
ctx.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Span_Control.ts
|
||||
* 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값.
|
||||
*
|
||||
* `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02).
|
||||
* 세션 맵(`extraSpans`)과 캐시(design)·정본 큐(`culvertOptions`)를 함께 만지는
|
||||
* 자리라 한 덩어리로 옮겼다. 판정 규칙은 종전대로 `_Cross_Culvert_Const` 몫이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
tierSpanOf,
|
||||
type SpanValues,
|
||||
type StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 구간값 제어가 페이지·상위 제어기에서 받아 쓰는 창구. */
|
||||
export interface SpanControlDeps {
|
||||
detail: () => { cross_sections: CrossSection[] } | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 연동 구조물의 소유 측점 — 값은 소유 측점 하나에만 담는다. */
|
||||
ownerOf: (section: CrossSection) => CrossSection | null;
|
||||
/** 세션 맵(누가거리:벽키 → 구간값)과 저장. */
|
||||
extraSpans: Map<string, SpanValues>;
|
||||
persistExtraSpans: () => void;
|
||||
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
|
||||
/** 정본 반영 큐(관 옵션). */
|
||||
culvertOptions: { queue: (chainageM: number, values: Record<string, number>) => void };
|
||||
}
|
||||
|
||||
/** 구간값 제어기와 단별 구간값 조회를 만든다. */
|
||||
export function createSpanControl(deps: SpanControlDeps): {
|
||||
control: StructureSpanControl;
|
||||
/** 이 측점의 단별 구간값 전부(세션 우선) — 상위 제어기가 payload에 실을 때 쓴다. */
|
||||
tierSpansOf: (owner: CrossSection) => Record<string, StoredWallSpan>;
|
||||
spanKeyOf: (chainageM: number, wall: string) => string;
|
||||
} {
|
||||
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf, culvertOptions } = deps;
|
||||
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
|
||||
|
||||
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
|
||||
const span = tierSpanOf(owner, wall);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
/** 이 측점의 단별 구간값 전부(세션 우선) — 캐시·payload에 실을 모양으로. */
|
||||
const tierSpansOf = (owner: CrossSection): Record<string, StoredWallSpan> => {
|
||||
const prefix = `${owner.chainage_m.toFixed(2)}:`;
|
||||
const result: Record<string, StoredWallSpan> = { ...(owner.design?.extra_spans ?? {}) };
|
||||
extraSpans.forEach((value, key) => {
|
||||
if (!key.startsWith(prefix)) return;
|
||||
result[key.slice(prefix.length)] = {
|
||||
length_m: value.lengthM,
|
||||
before_m: value.beforeM,
|
||||
after_m: value.afterM,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const control: StructureSpanControl = {
|
||||
ownerOf,
|
||||
tierValuesFor: (section, key) => {
|
||||
const owner = ownerOf(section) ?? section;
|
||||
return extraSpans.get(spanKeyOf(owner.chainage_m, key)) ?? storedTierSpan(owner, key);
|
||||
},
|
||||
updateTier: (section, key, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner) return;
|
||||
const mapKey = spanKeyOf(owner.chainage_m, key);
|
||||
const current = extraSpans.get(mapKey) ?? storedTierSpan(owner, key);
|
||||
const next = CulvertConst.applySpanPatch(current, patch);
|
||||
extraSpans.set(mapKey, next);
|
||||
persistExtraSpans();
|
||||
// 캐시(design)에도 얹는다 — 링크 판정·3D가 순수 함수로 이 값을 읽는다.
|
||||
patchCachedDesign(owner.chainage_m, { extra_spans: tierSpansOf(owner) });
|
||||
// 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만.
|
||||
const reach = Math.max(current.beforeM, current.afterM, next.beforeM, next.afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
valuesFor: (section, role) => {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = CulvertConst.spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이↔전/후 산식은 다단과 공용(`applySpanPatch`).
|
||||
const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch);
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
spec.basin_after_m = afterM;
|
||||
} else {
|
||||
spec.revet_length_m = lengthM;
|
||||
spec.revet_before_m = beforeM;
|
||||
spec.revet_after_m = afterM;
|
||||
}
|
||||
culvertOptions.queue(owner.chainage_m, {
|
||||
[keys.length]: lengthM,
|
||||
[keys.before]: beforeM,
|
||||
[keys.after]: afterM,
|
||||
});
|
||||
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
|
||||
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
|
||||
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
|
||||
// 2번만 반영).
|
||||
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { control, tierSpansOf, spanKeyOf };
|
||||
}
|
||||
@@ -23,16 +23,11 @@ import type {
|
||||
StationWidthControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
culvertOwnerFor,
|
||||
culvertReach,
|
||||
tierSpanOf,
|
||||
wallStandsAt,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
||||
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session";
|
||||
import { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session";
|
||||
import { createSpanControl } from "./B06_Section_UI_Page_Span_Control";
|
||||
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import type { BoxAdjust } from "./B06_Section_UI_Cross_Box";
|
||||
@@ -127,40 +122,16 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 카드 하단 ◀/▶/↺으로 1m씩 조절. 세션에 보관했다가 종/횡단 확정·임시저장 때
|
||||
* cross_patches(design.display_half_width_m)로 영구 저장돼 재접근 시 유지된다.
|
||||
* 값 우선순위: 세션 → 저장값(design) → 없음(전역 반폭). */
|
||||
const stationWidths = new Map<string, number>();
|
||||
const widthSession = createSessionMap<number>(
|
||||
() => deps.sessionKey("crossw"),
|
||||
(value) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined,
|
||||
);
|
||||
const stationWidths = widthSession.values;
|
||||
const loadStationWidths = widthSession.load;
|
||||
const persistStationWidths = widthSession.persist;
|
||||
const widthKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
const widthSessionKey = (): string | null => deps.sessionKey("crossw");
|
||||
|
||||
function loadStationWidths(): void {
|
||||
stationWidths.clear();
|
||||
const key = widthSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, width]) => {
|
||||
if (Number.isFinite(width) && width > 0) stationWidths.set(chainage, width);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 저장값·전역 반폭으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistStationWidths(): void {
|
||||
const key = widthSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(stationWidths)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 반폭 하한 2m. **상한은 두지 않는다**(2026-08-23 개편) — 계산 반폭(20m)을
|
||||
* 넘는 값은 `deps.ensureSampledWidth`가 백엔드 재생성으로 샘플을 넓힌 뒤 적용된다.
|
||||
*/
|
||||
const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2);
|
||||
|
||||
const stationWidthControl: StationWidthControl = {
|
||||
@@ -193,45 +164,29 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로
|
||||
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
|
||||
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
|
||||
const revetShifts = new Map<string, WallAdjust>();
|
||||
const revetSession = createSessionMap<WallAdjust>(
|
||||
() => deps.sessionKey("revetx"),
|
||||
(value) => {
|
||||
// 구 형식(숫자 = x 이동량)도 읽어 준다.
|
||||
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
|
||||
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
|
||||
const partial = value as Partial<WallAdjust>;
|
||||
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
|
||||
},
|
||||
);
|
||||
const revetShifts = revetSession.values;
|
||||
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
|
||||
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
|
||||
let revetSelection: { at: string; key: RevetKey } | null = null;
|
||||
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
|
||||
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
|
||||
|
||||
function loadRevetShifts(): void {
|
||||
revetShifts.clear();
|
||||
revetSession.load();
|
||||
revetSelection = null;
|
||||
const key = revetSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number | Partial<WallAdjust>>;
|
||||
Object.entries(parsed).forEach(([entry, value]) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
revetShifts.set(entry, { ...ZERO_ADJUST, x: value });
|
||||
} else if (value && typeof value === "object") {
|
||||
// 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로
|
||||
// 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다.
|
||||
revetShifts.set(entry, { ...ZERO_ADJUST, ...value, d: value.d ? value.d : null });
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 자동 자리로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistRevetShifts(): void {
|
||||
const key = revetSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(revetShifts)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
const persistRevetShifts = revetSession.persist;
|
||||
|
||||
/** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
@@ -340,92 +295,37 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
};
|
||||
/* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ────────────────
|
||||
* auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */
|
||||
const inletStructures = new Map<string, InletStructureChoice>();
|
||||
const basinAdjustments = new Map<string, BasinAdjust>();
|
||||
const structSessionKey = (): string | null => deps.sessionKey("inletstruct");
|
||||
const basinSessionKey = (): string | null => deps.sessionKey("basinadjust");
|
||||
|
||||
function loadBasinAdjustments(): void {
|
||||
basinAdjustments.clear();
|
||||
const key = basinSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
|
||||
string,
|
||||
BasinAdjust
|
||||
>;
|
||||
Object.entries(parsed).forEach(([chainage, value]) =>
|
||||
basinAdjustments.set(chainage, { ...DEFAULT_BASIN_ADJUST, ...value }),
|
||||
);
|
||||
} catch {
|
||||
/* 손상된 세션 값은 기본값으로 대체. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistBasinAdjustments(): void {
|
||||
const key = basinSessionKey();
|
||||
if (key)
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(basinAdjustments)));
|
||||
}
|
||||
|
||||
function loadInletStructures(): void {
|
||||
inletStructures.clear();
|
||||
const key = structSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, InletStructureChoice>;
|
||||
Object.entries(parsed).forEach(([chainage, value]) => {
|
||||
if (["auto", "revet", "I", "L", "U"].includes(value)) inletStructures.set(chainage, value);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — auto(규칙)로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistInletStructures(): void {
|
||||
const key = structSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(inletStructures)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
const basinSession = createSessionMap<BasinAdjust>(
|
||||
() => deps.sessionKey("basinadjust"),
|
||||
(value) =>
|
||||
value && typeof value === "object"
|
||||
? { ...DEFAULT_BASIN_ADJUST, ...(value as Partial<BasinAdjust>) }
|
||||
: undefined,
|
||||
);
|
||||
const structSession = createSessionMap<InletStructureChoice>(
|
||||
() => deps.sessionKey("inletstruct"),
|
||||
(value) =>
|
||||
typeof value === "string" && ["auto", "revet", "I", "L", "U"].includes(value)
|
||||
? (value as InletStructureChoice)
|
||||
: undefined,
|
||||
);
|
||||
const inletStructures = structSession.values;
|
||||
const basinAdjustments = basinSession.values;
|
||||
const loadBasinAdjustments = basinSession.load;
|
||||
const persistBasinAdjustments = basinSession.persist;
|
||||
const loadInletStructures = structSession.load;
|
||||
const persistInletStructures = structSession.persist;
|
||||
|
||||
/* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ──
|
||||
* 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */
|
||||
const extraCounts = new Map<string, number>();
|
||||
const extraSessionKey = (): string | null => deps.sessionKey("extrawall");
|
||||
const extraSession = createSessionMap<number>(
|
||||
() => deps.sessionKey("extrawall"),
|
||||
(value) => (Number.isInteger(value) && (value as number) > 0 ? (value as number) : undefined),
|
||||
);
|
||||
const extraCounts = extraSession.values;
|
||||
const loadExtraCounts = extraSession.load;
|
||||
const persistExtraCounts = extraSession.persist;
|
||||
|
||||
function loadExtraCounts(): void {
|
||||
extraCounts.clear();
|
||||
const key = extraSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, number>;
|
||||
Object.entries(parsed).forEach(([chainage, count]) => {
|
||||
if (Number.isInteger(count) && count > 0) extraCounts.set(chainage, count);
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 추가 벽 없음으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistExtraCounts(): void {
|
||||
const key = extraSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraCounts)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */
|
||||
const pendingEqualize = new Set<string>();
|
||||
|
||||
// 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다.
|
||||
@@ -551,130 +451,30 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져
|
||||
* 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다
|
||||
* (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */
|
||||
const extraSpans = new Map<string, SpanValues>();
|
||||
const extraSpanSessionKey = (): string | null => deps.sessionKey("extraspan");
|
||||
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
|
||||
const extraSpanSession = createSessionMap<SpanValues>(
|
||||
() => deps.sessionKey("extraspan"),
|
||||
(value) => {
|
||||
const span = value as SpanValues | null;
|
||||
return span && Number.isFinite(span.beforeM) && Number.isFinite(span.afterM)
|
||||
? span
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
const extraSpans = extraSpanSession.values;
|
||||
const loadExtraSpans = extraSpanSession.load;
|
||||
const persistExtraSpans = extraSpanSession.persist;
|
||||
|
||||
function loadExtraSpans(): void {
|
||||
extraSpans.clear();
|
||||
const key = extraSpanSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Record<string, SpanValues>;
|
||||
Object.entries(parsed).forEach(([mapKey, value]) => {
|
||||
if (value && Number.isFinite(value.beforeM) && Number.isFinite(value.afterM)) {
|
||||
extraSpans.set(mapKey, value);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 기본 구간값으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistExtraSpans(): void {
|
||||
const key = extraSpanSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraSpans)));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션에 없을 때의 단별 구간값 — 정본(`design.extra_spans`) → **소유 벽 연장 상속**
|
||||
* 순서다(2026-08-30 사용자: 손대기 전에는 기준벽과 같이 옆 측점에 서야 한다).
|
||||
* 상속 규칙은 그리기·링크 판정이 쓰는 `tierSpanOf`와 같은 함수 하나로 맞춘다.
|
||||
*/
|
||||
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
|
||||
const span = tierSpanOf(owner, wall);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
/** 이 측점의 단별 구간값 전부(세션 우선) — 캐시·payload에 실을 모양으로. */
|
||||
const tierSpansOf = (owner: CrossSection): Record<string, StoredWallSpan> => {
|
||||
const prefix = `${owner.chainage_m.toFixed(2)}:`;
|
||||
const result: Record<string, StoredWallSpan> = { ...(owner.design?.extra_spans ?? {}) };
|
||||
extraSpans.forEach((value, key) => {
|
||||
if (!key.startsWith(prefix)) return;
|
||||
result[key.slice(prefix.length)] = {
|
||||
length_m: value.lengthM,
|
||||
before_m: value.beforeM,
|
||||
after_m: value.afterM,
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const structureSpanControl: StructureSpanControl = {
|
||||
const spanControl = createSpanControl({
|
||||
detail: () => deps.detail(),
|
||||
refreshCard: (chainageM) => deps.refreshCard(chainageM),
|
||||
ownerOf,
|
||||
tierValuesFor: (section, key) => {
|
||||
const owner = ownerOf(section) ?? section;
|
||||
return extraSpans.get(spanKeyOf(owner.chainage_m, key)) ?? storedTierSpan(owner, key);
|
||||
},
|
||||
updateTier: (section, key, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner) return;
|
||||
const mapKey = spanKeyOf(owner.chainage_m, key);
|
||||
const current = extraSpans.get(mapKey) ?? storedTierSpan(owner, key);
|
||||
const next = CulvertConst.applySpanPatch(current, patch);
|
||||
extraSpans.set(mapKey, next);
|
||||
persistExtraSpans();
|
||||
// 캐시(design)에도 얹는다 — 링크 판정·3D가 순수 함수로 이 값을 읽는다.
|
||||
patchCachedDesign(owner.chainage_m, { extra_spans: tierSpansOf(owner) });
|
||||
// 연장이 바뀌면 링크되는 옆 측점이 달라진다 — 옛 연장·새 연장을 합친 구간만.
|
||||
const reach = Math.max(current.beforeM, current.afterM, next.beforeM, next.afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
valuesFor: (section, role) => {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = CulvertConst.spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이↔전/후 산식은 다단과 공용(`applySpanPatch`).
|
||||
const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch);
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
spec.basin_after_m = afterM;
|
||||
} else {
|
||||
spec.revet_length_m = lengthM;
|
||||
spec.revet_before_m = beforeM;
|
||||
spec.revet_after_m = afterM;
|
||||
}
|
||||
culvertOptions.queue(owner.chainage_m, {
|
||||
[keys.length]: lengthM,
|
||||
[keys.before]: beforeM,
|
||||
[keys.after]: afterM,
|
||||
});
|
||||
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
|
||||
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
|
||||
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
|
||||
// 2번만 반영).
|
||||
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
extraSpans,
|
||||
persistExtraSpans,
|
||||
patchCachedDesign,
|
||||
culvertOptions,
|
||||
});
|
||||
const structureSpanControl = spanControl.control;
|
||||
const spanKeyOf = spanControl.spanKeyOf;
|
||||
|
||||
/** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */
|
||||
const followGradeOf = (section: CrossSection): boolean => {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
export interface DesignDrawingItem {
|
||||
id: string;
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
confirmed: boolean;
|
||||
@@ -68,10 +67,7 @@ export interface CrossDesignInfo {
|
||||
cross_slope_pct?: number;
|
||||
paved?: boolean;
|
||||
ditch: DitchSpec;
|
||||
road_edges?: Record<
|
||||
"left" | "right",
|
||||
{ offset_m: number; elevation_m: number }
|
||||
>;
|
||||
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
@@ -84,8 +80,7 @@ export interface DesignDrawingResponse {
|
||||
route_id: number;
|
||||
id: string;
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
confirmed: boolean;
|
||||
@@ -102,10 +97,7 @@ export interface DesignDrawingConfirmResponse {
|
||||
design?: CrossDesignInfo | null;
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -116,17 +108,14 @@ async function requestJson<T>(
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok)
|
||||
throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchDesignDrawingList(
|
||||
projectId: string,
|
||||
): Promise<DesignDrawingListResponse> {
|
||||
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
|
||||
return requestJson(`/projects/${projectId}/design-drawings`);
|
||||
}
|
||||
|
||||
@@ -134,9 +123,7 @@ export function fetchDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<DesignDrawingResponse> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
|
||||
);
|
||||
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
|
||||
}
|
||||
|
||||
export function confirmDesignDrawing(
|
||||
@@ -154,10 +141,7 @@ export function confirmDesignDrawing(
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidateDesignDrawing(
|
||||
projectId: string,
|
||||
drawingId: string,
|
||||
): Promise<void> {
|
||||
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
|
||||
return requestJson(
|
||||
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
|
||||
{ method: "POST" },
|
||||
@@ -173,16 +157,11 @@ export interface FrameTemplateResponse {
|
||||
customized: boolean;
|
||||
}
|
||||
|
||||
export function fetchFrameTemplate(
|
||||
projectId: string,
|
||||
): Promise<FrameTemplateResponse> {
|
||||
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`);
|
||||
}
|
||||
|
||||
export function saveFrameTemplate(
|
||||
projectId: string,
|
||||
drawing: CadDrawing,
|
||||
): Promise<void> {
|
||||
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ drawing }),
|
||||
|
||||
@@ -35,6 +35,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
usable_area,
|
||||
)
|
||||
from config.config_system import DRAINAGE_RECOMMEND_DIAMETERS_MM, DRAWING_SCALE_BASIN
|
||||
@@ -400,7 +401,10 @@ def build_watershed_drawing(
|
||||
)
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
drawing_id, entities_bbox(entities) or bbox, fit=False, fields={"도면명": "유역도"}
|
||||
drawing_id,
|
||||
entities_bbox(entities) or bbox,
|
||||
fit=False,
|
||||
fields={"도면명": "유역도", **scale_fields(("", DRAWING_SCALE_BASIN))},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_layer,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
_A1_INNER,
|
||||
_fill_placeholders,
|
||||
_load_template,
|
||||
frame_entities,
|
||||
usable_bbox,
|
||||
)
|
||||
|
||||
COVER_TEMPLATE = "00_template_cover"
|
||||
@@ -42,8 +42,8 @@ NOTE_LAYER_ID = "b08-cover-note"
|
||||
def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""표지 도면 문서. 템플릿이 없으면 빈 도면을 낸다.
|
||||
|
||||
`fields` 의 `{{키}}` 는 도각과 같은 규약으로 치환하고, 값이 없으면 빈칸으로 둔다
|
||||
(남의 값이 남지 않는다). 값 공급은 다음 판(메타 배선) 몫이다.
|
||||
`{{키}}` 는 도각과 같은 규약으로 치환한다 — 값은 요청 문맥(`use_title_fields`)에서
|
||||
오고, `fields` 로 준 것이 그 위에 얹힌다. 값이 없으면 빈칸(남의 값이 남지 않는다).
|
||||
"""
|
||||
template = _load_template(COVER_TEMPLATE) or {}
|
||||
entities: list[dict[str, Any]] = []
|
||||
@@ -55,10 +55,9 @@ def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) -
|
||||
if isinstance(shape, dict):
|
||||
placed["shapeData"] = dict(shape)
|
||||
entities.append(placed)
|
||||
_fill_placeholders(entities, fields or {})
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"entities": _fill_placeholders(entities, fields or {}),
|
||||
"layers": [
|
||||
_layer(NOTE_LAYER_ID, "주기"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
@@ -74,8 +73,9 @@ def build_blank_drawing(drawing_id: str, label: str) -> dict[str, Any]:
|
||||
"""
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
# fit=False — 담을 콘텐츠가 없으니 A1 실치수 그대로 둔다.
|
||||
"entities": frame_entities(drawing_id, _A1_INNER, fit=False, fields={"도면명": label}),
|
||||
# fit=False — 담을 콘텐츠가 없으니 A1 실치수 그대로 둔다. bbox는 작도 영역이
|
||||
# 아니라 **수용 한도**를 준다(`_A1_INNER`를 주면 여백만큼 넘쳐 경고가 뜬다).
|
||||
"entities": frame_entities(drawing_id, usable_bbox(), fit=False, fields={"도면명": label}),
|
||||
"layers": [
|
||||
_layer(BLANK_LAYER_ID, "작도"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
|
||||
@@ -44,6 +44,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import DRAWING_SCALE_LONG_H, DRAWING_SCALE_LONG_V
|
||||
@@ -669,7 +670,17 @@ def build_longitudinal_drawing(
|
||||
# A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다.
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
entities.extend(frame_entities(drawing_id, bbox, fit=False, fields={"도면명": "종단면도"}))
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
drawing_id,
|
||||
bbox,
|
||||
fit=False,
|
||||
fields={
|
||||
"도면명": "종단면도",
|
||||
**scale_fields(("H", DRAWING_SCALE_LONG_H), ("V", DRAWING_SCALE_LONG_V)),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
|
||||
@@ -34,7 +34,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
polyline_entity,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, frame_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAWING_SCALE_MASSHAUL_H,
|
||||
DRAWING_SCALE_MASSHAUL_V_M3_MM,
|
||||
@@ -519,7 +523,10 @@ def build_mass_haul_drawing(
|
||||
)
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
drawing_id, entities_bbox(entities) or bbox, fit=False, fields={"도면명": "토적도"}
|
||||
drawing_id,
|
||||
entities_bbox(entities) or bbox,
|
||||
fit=False,
|
||||
fields={"도면명": "토적도", **scale_fields(("H", DRAWING_SCALE_MASSHAUL_H))},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -31,8 +31,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
entities_bbox,
|
||||
frame_entities,
|
||||
scale_fields,
|
||||
usable_area,
|
||||
)
|
||||
from config.config_system import DRAWING_SCALE_CROSS
|
||||
|
||||
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
|
||||
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
|
||||
@@ -209,7 +211,14 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
entities.extend(frame_entities(sheet["id"], bbox, fit=False, fields={"도면명": "횡단면도"}))
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
sheet["id"],
|
||||
bbox,
|
||||
fit=False,
|
||||
fields={"도면명": "횡단면도", **scale_fields(("", DRAWING_SCALE_CROSS))},
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
|
||||
@@ -52,6 +52,25 @@ def use_company_templates(company_dir: Path | None) -> None:
|
||||
_company_dir.set(company_dir)
|
||||
|
||||
|
||||
# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 —
|
||||
# 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다.
|
||||
_title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={})
|
||||
|
||||
|
||||
def use_title_fields(fields: dict[str, str] | None) -> None:
|
||||
"""이 요청이 도각 표제란에 채울 값을 정한다. None이면 표제란이 전부 빈칸이다."""
|
||||
_title_fields.set(fields or {})
|
||||
|
||||
|
||||
def add_title_fields(extra: dict[str, str]) -> None:
|
||||
"""이미 세운 표제란 값에 몇 개를 덧붙인다.
|
||||
|
||||
도면번호처럼 **도면을 읽는 도중에야 아는 값**을 위해서다. 라우터가 DB 값을 먼저 세우고,
|
||||
작도 스레드가 여기서 나머지를 얹는다(`asyncio.to_thread` 가 문맥을 복사한다).
|
||||
"""
|
||||
_title_fields.set({**_title_fields.get(), **(extra or {})})
|
||||
|
||||
|
||||
def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
|
||||
"""회사 도각 파일 경로(없을 수도 있다)."""
|
||||
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json"
|
||||
@@ -196,6 +215,14 @@ def _transform_entity(
|
||||
p = shape.get(key)
|
||||
if isinstance(p, dict):
|
||||
new_shape[key] = {"x": p["x"] * scale + dx, "y": p["y"] * scale + dy}
|
||||
# 꼭짓점 배열을 쓰는 엔티티(Image 로고·서명, Hatch 띠)도 함께 옮긴다.
|
||||
points = shape.get("points")
|
||||
if isinstance(points, list):
|
||||
new_shape["points"] = [
|
||||
{"x": point["x"] * scale + dx, "y": point["y"] * scale + dy}
|
||||
for point in points
|
||||
if isinstance(point, dict)
|
||||
]
|
||||
if "radius" in shape:
|
||||
new_shape["radius"] = shape["radius"] * scale
|
||||
options = shape.get("options")
|
||||
@@ -241,6 +268,19 @@ def compass_entities(
|
||||
]
|
||||
|
||||
|
||||
def usable_bbox() -> tuple[float, float, float, float]:
|
||||
"""작도 영역 한가운데에 놓인 **수용 한도 크기**의 빈 bbox.
|
||||
|
||||
담을 내용이 없는 도면(빈 도면)이 도각만 두를 때 쓴다. `_A1_INNER`를 그대로 넘기면
|
||||
여백을 뺀 한도(`usable_area()`)보다 커서 "작도 영역을 넘습니다" 경고가 뜬다 —
|
||||
내용이 없는데 넘칠 리 없다. 중심이 같으므로 도각 배치(이동량 0)는 그대로다.
|
||||
"""
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
width, height = usable_area()
|
||||
cx, cy = (ix0 + ix1) / 2.0, (iy0 + iy1) / 2.0
|
||||
return (cx - width / 2.0, cy - height / 2.0, cx + width / 2.0, cy + height / 2.0)
|
||||
|
||||
|
||||
def usable_area() -> tuple[float, float]:
|
||||
"""A1 내부 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
@@ -253,17 +293,54 @@ def usable_area() -> tuple[float, float]:
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
|
||||
|
||||
|
||||
def _fill_placeholders(entities: list[dict[str, Any]], fields: dict[str, str]) -> None:
|
||||
"""도각 텍스트의 {{키}}를 값으로 바꾼다. 값이 없으면 빈칸 — 남의 값이 남지 않는다."""
|
||||
def _fill_placeholders(
|
||||
entities: list[dict[str, Any]], fields: dict[str, str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""도각의 {{키}}를 값으로 바꾼 엔티티 목록을 낸다. 값이 없으면 빈칸 — 남의 값이 남지 않는다.
|
||||
|
||||
요청 문맥의 표제란 값(`use_title_fields`)이 바탕이고, 인자로 준 값(도면마다 다른
|
||||
도면명 등)이 위에 얹힌다. 합치는 자리를 **치환 함수 한 곳**에 둬야 도각을 두르지
|
||||
않는 표지처럼 다른 경로로 들어온 도면도 같은 값을 받는다(2026-09-02 표지 누락).
|
||||
"""
|
||||
fields = {**_title_fields.get(), **(fields or {})}
|
||||
|
||||
def substitute(text: str) -> str:
|
||||
return _PLACEHOLDER.sub(lambda match: str(fields.get(match.group(1), "")), text)
|
||||
|
||||
for entity in entities:
|
||||
if entity.get("type") != "Text":
|
||||
continue
|
||||
shape = entity.get("shapeData") or {}
|
||||
# 글자 자리
|
||||
label = shape.get("label")
|
||||
if isinstance(label, str) and "{{" in label:
|
||||
shape["label"] = _PLACEHOLDER.sub(
|
||||
lambda match: str(fields.get(match.group(1), "")), label
|
||||
)
|
||||
if entity.get("type") == "Text" and isinstance(label, str) and "{{" in label:
|
||||
shape["label"] = substitute(label)
|
||||
# 그림 자리(회사 로고·개인 서명) — 값은 data URL. 못 구하면 그림을 통째로
|
||||
# 빼서 빈 칸으로 둔다(빈 문자열을 남기면 CAD가 깨진 그림으로 그린다).
|
||||
image = shape.get("imageData")
|
||||
if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image:
|
||||
shape["imageData"] = substitute(image)
|
||||
# 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다.
|
||||
return [
|
||||
entity
|
||||
for entity in entities
|
||||
if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData")
|
||||
]
|
||||
|
||||
|
||||
def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]:
|
||||
"""축척 칸(`{{축척_A1}}`·`{{축척_A3}}`)에 넣을 값.
|
||||
|
||||
도각이 `A1 = 1 :` 를 이미 찍으므로 **분모만** 낸다. A3 는 A1 도면을 절반으로 뽑는
|
||||
종이라 분모가 2배다. 가로·세로 축척이 다른 도면(종단·토적)은 이름표를 붙여 함께 적는다.
|
||||
값은 `config_system` 의 축척 상수에서 오며 여기서 새로 정하지 않는다.
|
||||
"""
|
||||
|
||||
def text(factor: int) -> str:
|
||||
return " · ".join(
|
||||
f"{denominator * factor}({name})" if name else str(denominator * factor)
|
||||
for name, denominator in denominators
|
||||
)
|
||||
|
||||
return {"축척_A1": text(1), "축척_A3": text(2)}
|
||||
|
||||
|
||||
def frame_entities(
|
||||
@@ -289,7 +366,9 @@ def frame_entities(
|
||||
ix0, iy0, ix1, iy1 = _A1_INNER
|
||||
usable_w, usable_h = usable_area()
|
||||
scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0
|
||||
if not fit and (content_w > usable_w or content_h > usable_h):
|
||||
# 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다
|
||||
# (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다).
|
||||
if not fit and (content_w > usable_w + 1e-6 or content_h > usable_h + 1e-6):
|
||||
logger.warning(
|
||||
"도면 콘텐츠가 A1 작도 영역을 넘습니다: %s (%.0fx%.0f mm > %.0fx%.0f mm)",
|
||||
drawing_id,
|
||||
@@ -307,5 +386,4 @@ def frame_entities(
|
||||
_transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy)
|
||||
for index, entity in enumerate(template.get("entities", []))
|
||||
]
|
||||
_fill_placeholders(placed, fields or {})
|
||||
return placed
|
||||
return _fill_placeholders(placed, fields or {})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from base64 import b64encode
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -30,6 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
frame_template_document,
|
||||
save_company_template,
|
||||
use_company_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
MASS_HAUL_ID,
|
||||
@@ -53,7 +55,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
FrameTemplateSaveResponse,
|
||||
)
|
||||
from common_util.common_util_drainage_context import load_drainage_context
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -96,6 +98,95 @@ async def _company_dir(project_id: UUID) -> Path:
|
||||
return root.parent.parent
|
||||
|
||||
|
||||
def _asset_data_url(relative_path: str | None) -> str:
|
||||
"""회사 로고·개인 서명 파일을 CAD `ImageEntity`가 읽는 data URL로 만든다.
|
||||
|
||||
파일이 없거나 경로가 수상하면 빈 문자열 — 그 자리는 그림째 빠진다
|
||||
(`_fill_placeholders`). 그림은 DB에 경로만 담는 기존 방식 그대로다.
|
||||
"""
|
||||
blob = read_stored_asset(relative_path)
|
||||
if not blob:
|
||||
return ""
|
||||
suffix = PurePosixPath(str(relative_path)).suffix.lower()
|
||||
mime = "image/svg+xml" if suffix == ".svg" else f"image/{suffix.lstrip('.') or 'png'}"
|
||||
return f"data:{mime};base64,{b64encode(blob).decode('ascii')}"
|
||||
|
||||
|
||||
async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
||||
"""도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다.
|
||||
|
||||
담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이
|
||||
박혀 있어도 도면에는 나가지 않는다(2026-08-31 사용자 확정, 이것이 1순위 목적).
|
||||
|
||||
사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는
|
||||
배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게.
|
||||
로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다 —
|
||||
안 골랐거나 자산이 지워졌으면 그림째 빠진다. 011 의 `companies.logo_path` ·
|
||||
`users.signature_path` 는 더 읽지 않는다.
|
||||
|
||||
아직 못 채우는 자리와 이유:
|
||||
- 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸.
|
||||
- 설계일자·도면번호는 여기서 담지 않는다 — 설계일자는 사용자가 넣은 `design_date`
|
||||
를 그대로 쓰고(확정일 자동이 아니다), 도면번호는 도면을 읽는 쪽이 manifest 에
|
||||
적힌 목록 순번을 얹는다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount,
|
||||
p.design_date, c.name, logo.file_path,
|
||||
COALESCE(designer.name, owner.name), sig.file_path,
|
||||
pm.name, lead.name
|
||||
FROM projects p
|
||||
LEFT JOIN companies c ON c.id = p.company_id
|
||||
LEFT JOIN users owner ON owner.id = p.user_id
|
||||
LEFT JOIN users designer ON designer.id = p.designer_user_id
|
||||
LEFT JOIN users pm ON pm.id = p.pm_user_id
|
||||
LEFT JOIN users lead ON lead.id = p.field_lead_user_id
|
||||
LEFT JOIN company_assets logo
|
||||
ON logo.id = p.logo_asset_id AND logo.deleted_at IS NULL
|
||||
LEFT JOIN company_assets sig
|
||||
ON sig.id = p.signature_asset_id AND sig.deleted_at IS NULL
|
||||
WHERE p.id = %s AND p.deleted_at IS NULL
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
(
|
||||
name,
|
||||
region,
|
||||
client_org,
|
||||
project_number,
|
||||
work_amount,
|
||||
design_date,
|
||||
company,
|
||||
logo_path,
|
||||
designer,
|
||||
signature_path,
|
||||
pm,
|
||||
lead,
|
||||
) = row
|
||||
fields = {
|
||||
"공사명": name,
|
||||
"위치": region,
|
||||
"시행청": client_org,
|
||||
"연도기번": project_number,
|
||||
"사업량": work_amount,
|
||||
# 도면 표기 관행대로 "2026. 09. 02." 꼴로 적는다.
|
||||
"설계일자": f"{design_date:%Y. %m. %d.}" if design_date else None,
|
||||
"용역회사": company,
|
||||
"설계자": designer,
|
||||
"과업책임자": pm,
|
||||
"분야별책임자": lead,
|
||||
"회사로고": _asset_data_url(logo_path),
|
||||
"설계자서명": _asset_data_url(signature_path),
|
||||
}
|
||||
return {key: str(value) for key, value in fields.items() if value}
|
||||
|
||||
|
||||
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
|
||||
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
|
||||
pool = get_db_pool()
|
||||
@@ -142,6 +233,8 @@ async def get_design_drawing(
|
||||
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
|
||||
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
|
||||
use_company_templates(project_root.parent.parent)
|
||||
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
|
||||
use_title_fields(await _title_block_fields(project_id))
|
||||
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
||||
design: dict[str, Any] | None = None
|
||||
source_design: Any = None
|
||||
|
||||
@@ -40,6 +40,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
QUANTITY_VALUE_KEYS,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DesignDrawingItem,
|
||||
)
|
||||
@@ -417,9 +418,29 @@ def _drawing_list(
|
||||
# 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다.
|
||||
for drawing_id, label in BLANK_DRAWINGS:
|
||||
drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label))
|
||||
_store_drawing_numbers(project_root, drawings)
|
||||
return drawings
|
||||
|
||||
|
||||
def _store_drawing_numbers(project_root: Path, drawings: list[DesignDrawingItem]) -> None:
|
||||
"""도면번호(목록 순번)를 manifest 에 적어 둔다.
|
||||
|
||||
단건 조회는 목록 순서를 모른다 — 알려면 횡단 장 계획을 다시 계산해야 하고, 그것을 도면
|
||||
열 때마다 하면 비싸다. 목록은 화면에 들어올 때 늘 먼저 뜨므로, 그때 매긴 번호를 적어
|
||||
두고 단건 조회는 그것을 읽는다. 목록이 바뀌면 다음 조회에서 다시 적힌다.
|
||||
"""
|
||||
manifest = _read_manifest(project_root)
|
||||
entries = manifest["drawings"]
|
||||
changed = False
|
||||
for number, item in enumerate(drawings, start=1):
|
||||
entry = entries.setdefault(item.id, {})
|
||||
if entry.get("number") != number:
|
||||
entry["number"] = number
|
||||
changed = True
|
||||
if changed:
|
||||
_write_manifest(project_root, manifest)
|
||||
|
||||
|
||||
def _cross_sheet_plan(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
@@ -590,6 +611,9 @@ def _read_drawing(
|
||||
stored_design(없으면 기본값)에서 만든다.
|
||||
"""
|
||||
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
|
||||
number = manifest_entry.get("number")
|
||||
if isinstance(number, int):
|
||||
add_title_fields({"도면번호": str(number)})
|
||||
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
|
||||
if manifest_entry.get("confirmed") and saved_path.is_file():
|
||||
saved = _read_json(saved_path)
|
||||
|
||||
@@ -37,9 +37,7 @@ interface Options {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function createFrameTemplateEditor(
|
||||
options: Options,
|
||||
): FrameTemplateEditor {
|
||||
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
|
||||
let editing = false;
|
||||
|
||||
const banner = document.createElement("div");
|
||||
@@ -94,10 +92,7 @@ export function createFrameTemplateEditor(
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각을 불러오지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +112,7 @@ export function createFrameTemplateEditor(
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "기본 도각으로 되돌리지 못했습니다.",
|
||||
error instanceof Error ? error.message : "기본 도각으로 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -134,16 +127,10 @@ export function createFrameTemplateEditor(
|
||||
const drawing = await options.requestCadDrawing();
|
||||
await saveFrameTemplate(options.projectId, drawing);
|
||||
options.onSaved();
|
||||
showToast(
|
||||
"도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.",
|
||||
"success",
|
||||
);
|
||||
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "도각을 저장하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
showToast(error instanceof Error ? error.message : "도각을 저장하지 못했습니다.", "error");
|
||||
} finally {
|
||||
finishButton.disabled = false;
|
||||
}
|
||||
|
||||
@@ -133,10 +133,7 @@ function buildDrawingSidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
const drawingButton = (
|
||||
drawing: DesignDrawingItem,
|
||||
label: string,
|
||||
): HTMLButtonElement => {
|
||||
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
@@ -175,8 +172,7 @@ function buildDrawingSidePanel(
|
||||
const button = drawingButton(drawing, group.label);
|
||||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||||
button.dataset.pending = String(drawing.kind === "blank");
|
||||
if (drawing.kind === "blank")
|
||||
button.title = "준비 중 — 도각만 표시합니다";
|
||||
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
@@ -198,10 +194,7 @@ function buildDrawingSidePanel(
|
||||
return panel;
|
||||
}
|
||||
|
||||
const GROUND_TYPE_LABEL: Record<
|
||||
CrossDesignInfo["ground_type"],
|
||||
keyof typeof ui_locales
|
||||
> = {
|
||||
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
|
||||
soil: "B06_Design_Ground_Soil",
|
||||
ripping_rock: "B06_Design_Ground_Ripping",
|
||||
blasting_rock: "B06_Design_Ground_Blasting",
|
||||
@@ -218,8 +211,7 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
|
||||
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
|
||||
function ditchLabel(design: CrossDesignInfo): string {
|
||||
const ditch = design.ditch;
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
|
||||
return "없음";
|
||||
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
|
||||
if (ditch.type === "l_type")
|
||||
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
|
||||
@@ -239,10 +231,7 @@ function infoRow(label: string, value: string): HTMLElement {
|
||||
}
|
||||
|
||||
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
|
||||
function buildDesignInfoPanel(
|
||||
title: string,
|
||||
design: CrossDesignInfo | null,
|
||||
): HTMLElement {
|
||||
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-info";
|
||||
const heading = document.createElement("div");
|
||||
@@ -252,9 +241,7 @@ function buildDesignInfoPanel(
|
||||
const confirmed = design?.status === "confirmed";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
|
||||
badge.textContent = confirmed
|
||||
? L("B07_Info_Confirmed")
|
||||
: L("B07_Info_Provisional");
|
||||
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
|
||||
heading.append(stationName, badge);
|
||||
panel.append(heading);
|
||||
|
||||
@@ -276,9 +263,7 @@ function buildDesignInfoPanel(
|
||||
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
|
||||
infoRow(
|
||||
L("B07_Info_DitchSide"),
|
||||
design.ditch_side === "left"
|
||||
? L("B06_Design_Ditch_Left")
|
||||
: L("B06_Design_Ditch_Right"),
|
||||
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -288,10 +273,7 @@ function buildDesignInfoPanel(
|
||||
planTitle.textContent = L("B07_Info_Plan_Title");
|
||||
plan.append(
|
||||
planTitle,
|
||||
infoRow(
|
||||
L("B07_Info_DesignElevation"),
|
||||
`${design.design_elevation_m.toFixed(2)}m`,
|
||||
),
|
||||
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
|
||||
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
|
||||
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
|
||||
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
|
||||
@@ -317,10 +299,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
fetchWorkflowState(projectId),
|
||||
fetchDesignDrawingList(projectId),
|
||||
]);
|
||||
if (workflowResult.status === "fulfilled")
|
||||
workflowState = workflowResult.value;
|
||||
if (drawingResult.status === "fulfilled")
|
||||
drawings = drawingResult.value.drawings;
|
||||
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
|
||||
if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings;
|
||||
else
|
||||
drawingError =
|
||||
drawingResult.reason instanceof Error
|
||||
@@ -350,25 +330,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
|
||||
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
|
||||
let allDrawingsConfirmed =
|
||||
drawings.some(isCross) &&
|
||||
drawings.filter(isCross).every((item) => item.confirmed);
|
||||
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
|
||||
let resolveSave: ((payload: SaveResult) => void) | undefined;
|
||||
let drawingListEl: HTMLElement | undefined;
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
|
||||
const updateInfoPanel = (
|
||||
drawing: DesignDrawingItem,
|
||||
response: DesignDrawingResponse,
|
||||
): void => {
|
||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||
if (drawing.kind !== "cross") {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const title = drawing.label;
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(title, response.design ?? null),
|
||||
);
|
||||
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -406,11 +380,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
) ?? undefined;
|
||||
|
||||
const highlightActive = (drawingId: string) => {
|
||||
drawingListEl
|
||||
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button")
|
||||
.forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
|
||||
item.dataset.active = String(item.dataset.drawingId === drawingId);
|
||||
});
|
||||
};
|
||||
|
||||
const buildMeta = (
|
||||
@@ -449,24 +421,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
|
||||
|
||||
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
|
||||
const requestDrawing = (
|
||||
drawing: DesignDrawingItem,
|
||||
): Promise<DesignDrawingResponse> => {
|
||||
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
|
||||
const cached = drawingCache.get(drawing.id);
|
||||
if (cached) return cached;
|
||||
const request = (async () => {
|
||||
const response = await fetchDesignDrawing(
|
||||
projectId as string,
|
||||
drawing.id,
|
||||
);
|
||||
const response = await fetchDesignDrawing(projectId as string, drawing.id);
|
||||
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
|
||||
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
|
||||
if (drawing.kind === "cross" && !response.confirmed) {
|
||||
await appendStructureEntities(
|
||||
projectId as string,
|
||||
response.route_id,
|
||||
response.drawing,
|
||||
);
|
||||
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
|
||||
}
|
||||
return response;
|
||||
})().catch((error) => {
|
||||
@@ -522,9 +485,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} catch (error) {
|
||||
cadHost.dataset.loading = "false";
|
||||
cadHost.dataset.error =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "CAD 도면을 불러오지 못했습니다.";
|
||||
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
@@ -549,10 +510,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const requestCadDrawing = (): Promise<SaveResult> =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveSave = resolve;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_SAVE_REQUEST_MESSAGE },
|
||||
window.location.origin,
|
||||
);
|
||||
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
|
||||
window.setTimeout(() => {
|
||||
if (!resolveSave) return;
|
||||
resolveSave = undefined;
|
||||
@@ -596,9 +554,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "현재 도면을 확정하지 못했습니다.",
|
||||
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -628,9 +584,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
@@ -650,11 +604,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadHost.prepend(frameEditor.banner);
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== frame.contentWindow
|
||||
)
|
||||
return;
|
||||
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
|
||||
const message = event.data as {
|
||||
type?: string;
|
||||
detail?: string;
|
||||
@@ -672,8 +622,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
(item) => item === message.kind,
|
||||
);
|
||||
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
|
||||
const duration =
|
||||
message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
|
||||
const actionId = message.actionId;
|
||||
showToast(
|
||||
message.text ?? "",
|
||||
@@ -693,8 +642,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
cadHost.dataset.error =
|
||||
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.loading = "false";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
@@ -704,11 +652,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (
|
||||
message.type === CAD_SAVE_RESPONSE_MESSAGE &&
|
||||
message.drawing &&
|
||||
resolveSave
|
||||
) {
|
||||
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
|
||||
const resolve = resolveSave;
|
||||
resolveSave = undefined;
|
||||
resolve({
|
||||
@@ -718,11 +662,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
const drawingPanel = buildDrawingSidePanel(
|
||||
drawings,
|
||||
selectDrawing,
|
||||
drawingError,
|
||||
);
|
||||
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
|
||||
@@ -744,10 +684,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
onStepClick: (stepIndex) => {
|
||||
if (!projectId) return;
|
||||
if (stepIndex > 5 && !allDrawingsConfirmed) {
|
||||
showToast(
|
||||
"모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.",
|
||||
"warning",
|
||||
);
|
||||
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
|
||||
return;
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
|
||||
@@ -116,11 +116,7 @@
|
||||
|
||||
/* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */
|
||||
.b07-drawing-button[data-confirmed="true"] {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-success) 35%,
|
||||
var(--color-border)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { HtmlEvent } from '../App.types';
|
||||
import type { Entity, JsonEntity } from '../entities/Entity';
|
||||
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities';
|
||||
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json';
|
||||
import { getActiveLayerId } from '../state';
|
||||
import { getActiveLayerId, notifyWindow } from '../state';
|
||||
|
||||
const STORAGE_KEY = 'aislo-cad-block-library';
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface BlockDefinition {
|
||||
|
||||
let blocks: BlockDefinition[] | null = null;
|
||||
|
||||
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
|
||||
function load(): BlockDefinition[] {
|
||||
if (blocks) return blocks;
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
wipeoutToolStateMachine,
|
||||
xlineToolStateMachine,
|
||||
} from '../tools/draw/construction-tools';
|
||||
import {
|
||||
divideToolStateMachine,
|
||||
measureLengthToolStateMachine,
|
||||
} from '../tools/draw/divide-tools';
|
||||
import { divideToolStateMachine, measureLengthToolStateMachine } from '../tools/draw/divide-tools';
|
||||
import {
|
||||
boundaryToolStateMachine,
|
||||
gradientToolStateMachine,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { getSelectedEntities, isDrawingReadOnly, setActiveToolActor } from '../state';
|
||||
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
|
||||
|
||||
@@ -19,7 +19,7 @@ function log(line: string) {
|
||||
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
|
||||
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
}
|
||||
|
||||
/** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {noop} from 'es-toolkit';
|
||||
import type {CSSProperties, FC, MouseEvent, ReactNode} from 'react';
|
||||
import {Icon, type IconName} from './Icon/Icon.tsx';
|
||||
import { noop } from 'es-toolkit';
|
||||
import type { CSSProperties, FC, MouseEvent, ReactNode } from 'react';
|
||||
import { Icon, type IconName } from './Icon/Icon.tsx';
|
||||
|
||||
interface ButtonProps {
|
||||
label?: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {CSSProperties, FC, ReactNode} from 'react';
|
||||
import type { CSSProperties, FC, ReactNode } from 'react';
|
||||
import useLocalStorageState from 'use-local-storage-state';
|
||||
import {LOCAL_STORAGE_KEY} from '../App.types.ts';
|
||||
import {keyboardHandler} from '../helpers/keyboard-handler.ts';
|
||||
import {Button} from './Button.tsx';
|
||||
import {Icon, IconName} from './Icon/Icon.tsx';
|
||||
import { LOCAL_STORAGE_KEY } from '../App.types.ts';
|
||||
import { keyboardHandler } from '../helpers/keyboard-handler.ts';
|
||||
import { Button } from './Button.tsx';
|
||||
import { Icon, IconName } from './Icon/Icon.tsx';
|
||||
|
||||
interface DropdownButtonProps {
|
||||
label?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {FC} from 'react';
|
||||
import type { FC } from 'react';
|
||||
import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react';
|
||||
import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react';
|
||||
import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react';
|
||||
|
||||
@@ -3,12 +3,7 @@ import type { FC } from 'react';
|
||||
import { LayerManager } from './LayerManager';
|
||||
import { PropertiesEditor } from './PropertiesEditor';
|
||||
import { getInspectorTab, openInspector } from './ui-state';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getLayers,
|
||||
setActiveLayerId,
|
||||
setLayers,
|
||||
} from '../state';
|
||||
import { getActiveLayerId, getLayers, setActiveLayerId, setLayers } from '../state';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
collapsed: boolean;
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
import type { FC } from 'react';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
|
||||
import {
|
||||
getEntities,
|
||||
getLayers,
|
||||
getSelectedEntities,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
import { getEntities, getLayers, getSelectedEntities, setEntities } from '../state';
|
||||
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
|
||||
|
||||
interface PropertiesEditorProps {
|
||||
@@ -139,9 +134,7 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
|
||||
</div>
|
||||
<div>
|
||||
<dt>시작점</dt>
|
||||
<dd>
|
||||
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
|
||||
</dd>
|
||||
<dd>{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>그룹</dt>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 리액트 컴포넌트 바깥에 둔다. 값이 바뀌면 UPDATE_STATE로 다시 그린다.
|
||||
*/
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { notifyWindow } from '../state';
|
||||
|
||||
export type InspectorTab = 'properties' | 'layers';
|
||||
|
||||
@@ -12,7 +13,7 @@ let quickPropertiesVisible = false;
|
||||
let activeRibbonTab = 'home';
|
||||
let blockLibraryVisible = false;
|
||||
|
||||
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
|
||||
export const getInspectorTab = () => inspectorTab;
|
||||
export const isInspectorCollapsed = () => inspectorCollapsed;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ScreenCanvasDrawController } from './screenCanvas.drawController';
|
||||
|
||||
/** translate·drawImage 인자만 기록하는 최소 2d 컨텍스트. */
|
||||
function recordingContext() {
|
||||
const calls: { translate: number[][]; rotate: number[]; image: number[] } = {
|
||||
translate: [],
|
||||
rotate: [],
|
||||
image: [],
|
||||
};
|
||||
const context = {
|
||||
translate: (x: number, y: number) => calls.translate.push([x, y]),
|
||||
rotate: (a: number) => calls.rotate.push(a),
|
||||
drawImage: (_img: unknown, ...rest: number[]) => calls.image.push(...rest),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
return { context, calls };
|
||||
}
|
||||
|
||||
describe('ScreenCanvasDrawController.drawImage', () => {
|
||||
it('그림 중심은 선·글자와 같은 뒤집힌 y 자리에 놓인다', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
const controller = new ScreenCanvasDrawController(context);
|
||||
controller.setCanvasSize(new Point(800, 600));
|
||||
controller.setScreenOffset(new Point(0, 0));
|
||||
// 세계 (10,-20)~(42,-4) 상자 — 도각 로고 자리처럼 원점 아래.
|
||||
controller.drawImage({} as HTMLImageElement, 10, -20, 32, 16, 0);
|
||||
const center = controller.worldToTarget(new Point(26, -12));
|
||||
expect(calls.translate[0]).toEqual([center.x, 600 - center.y]);
|
||||
expect(calls.image).toEqual([-16, -8, 32, 16]);
|
||||
});
|
||||
|
||||
it('회전은 y 뒤집기에 맞춰 부호를 바꾸고 원상 복구한다', () => {
|
||||
const { context, calls } = recordingContext();
|
||||
const controller = new ScreenCanvasDrawController(context);
|
||||
controller.drawImage({} as HTMLImageElement, 0, 0, 4, 2, 0.5);
|
||||
expect(calls.rotate).toEqual([-0.5, 0.5]);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,6 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
}
|
||||
|
||||
public setScreenScale(newScreenScale: number) {
|
||||
console.log(`set screen scale: ${newScreenScale}`);
|
||||
this.screenScale = newScreenScale;
|
||||
triggerReactUpdate(StateVariable.screenZoom);
|
||||
}
|
||||
@@ -597,20 +596,20 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
angle: number
|
||||
): void {
|
||||
if (this.batching) this.flushBatch();
|
||||
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
|
||||
new Point(xMin, yMin),
|
||||
new Point(width, height),
|
||||
]);
|
||||
const screenXMin = screenBasePoint.x;
|
||||
const screenYMin = screenBasePoint.y;
|
||||
const screenWidth = screenDimensions.x;
|
||||
const screenHeight = screenDimensions.y;
|
||||
const screenCenterX = screenXMin + screenWidth / 2;
|
||||
const screenCenterY = screenYMin + screenHeight / 2;
|
||||
// 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과
|
||||
// y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02
|
||||
// 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다.
|
||||
const screenWidth = width * this.screenScale;
|
||||
const screenHeight = height * this.screenScale;
|
||||
const screenCenter = this.worldToTarget(new Point(xMin + width / 2, yMin + height / 2));
|
||||
const screenCenterX = screenCenter.x;
|
||||
// 다른 그리기와 같이 y 를 뒤집는다 — worldToTarget 은 수학 좌표(위가 +y)라 그대로
|
||||
// 쓰면 그림이 거울상 자리(캔버스 밖)에 놓여 화면에 안 보인다(2026-09-02 도각 로고).
|
||||
const screenCenterY = this.canvasSize.y - screenCenter.y;
|
||||
|
||||
// Rotate and translate context
|
||||
// Rotate and translate context (y 를 뒤집었으니 회전 방향도 반대)
|
||||
this.context.translate(screenCenterX, screenCenterY);
|
||||
this.context.rotate(angle);
|
||||
this.context.rotate(-angle);
|
||||
|
||||
// Draw image
|
||||
this.context.drawImage(
|
||||
@@ -622,7 +621,7 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
);
|
||||
|
||||
// Reset context
|
||||
this.context.rotate(-angle);
|
||||
this.context.rotate(angle);
|
||||
this.context.translate(-screenCenterX, -screenCenterY);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Point, Vector} from '@flatten-js/core';
|
||||
import {toast} from 'react-toastify';
|
||||
import {SVG_MARGIN, TO_DEGREES} from '../App.consts.ts';
|
||||
import type {TextOptions} from '../entities/TextEntity.ts';
|
||||
import {isLengthEqual} from '../helpers/is-length-equal.ts';
|
||||
import {StateVariable} from '../helpers/undo-stack.ts';
|
||||
import {triggerReactUpdate} from '../state.ts';
|
||||
import {DEFAULT_TEXT_OPTIONS, type DrawController} from './DrawController';
|
||||
import { Point, Vector } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import { SVG_MARGIN, TO_DEGREES } from '../App.consts.ts';
|
||||
import type { TextOptions } from '../entities/TextEntity.ts';
|
||||
import { isLengthEqual } from '../helpers/is-length-equal.ts';
|
||||
import { StateVariable } from '../helpers/undo-stack.ts';
|
||||
import { triggerReactUpdate } from '../state.ts';
|
||||
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
||||
|
||||
export class SvgDrawController implements DrawController {
|
||||
private lineColor = '#000';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {type Arc, Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {EPSILON} from "../App.consts.ts";
|
||||
import {ArcEntity} from './ArcEntity.ts';
|
||||
import { type Arc, Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EPSILON } from '../App.consts.ts';
|
||||
import { ArcEntity } from './ArcEntity.ts';
|
||||
|
||||
describe('ArcEntity.distanceTo', () => {
|
||||
/**
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface JsonEntity<TShapeJsonData = ShapeJsonData> {
|
||||
lineWidth: number;
|
||||
lineDash?: number[];
|
||||
layerId: string;
|
||||
/** GROUP 묶음 식별자 — 저장·복원에서 그대로 실어 나른다 */
|
||||
groupId?: string;
|
||||
shapeData: TShapeJsonData | null;
|
||||
children?: JsonEntity<ShapeJsonData>[];
|
||||
}
|
||||
|
||||
@@ -89,7 +89,13 @@ export class HatchEntity implements Entity {
|
||||
}
|
||||
|
||||
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
|
||||
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
|
||||
drawController.setLineStyles(
|
||||
highlighted,
|
||||
selected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
for (let index = 1; index < this.points.length; index++) {
|
||||
drawController.drawLine(this.points[index - 1], this.points[index]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DrawController } from '../drawControllers/DrawController';
|
||||
import { ImageEntity } from './ImageEntity';
|
||||
|
||||
/** drawLine·drawImage 호출만 세는 최소 컨트롤러. */
|
||||
function countingController() {
|
||||
const calls = { lines: 0, images: 0 };
|
||||
const controller = {
|
||||
setLineStyles: () => {},
|
||||
drawLine: () => {
|
||||
calls.lines += 1;
|
||||
},
|
||||
drawImage: () => {
|
||||
calls.images += 1;
|
||||
},
|
||||
} as unknown as DrawController;
|
||||
return { controller, calls };
|
||||
}
|
||||
|
||||
function entity(): ImageEntity {
|
||||
const image = { currentSrc: 'data:image/png;base64,AAAA' } as HTMLImageElement;
|
||||
return new ImageEntity('b08-frame', image, new Point(0, 0), new Point(10, 5));
|
||||
}
|
||||
|
||||
describe('ImageEntity.draw', () => {
|
||||
it('집지 않은 그림은 테두리를 그리지 않는다', () => {
|
||||
const { controller, calls } = countingController();
|
||||
entity().draw(controller, false, false);
|
||||
expect(calls.lines).toBe(0);
|
||||
expect(calls.images).toBe(1);
|
||||
});
|
||||
|
||||
it('집은 그림만 테두리를 보인다', () => {
|
||||
const { controller, calls } = countingController();
|
||||
entity().draw(controller, false, true);
|
||||
expect(calls.lines).toBe(4);
|
||||
expect(calls.images).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -53,15 +53,21 @@ export class ImageEntity implements Entity {
|
||||
parentHighlighted?: boolean,
|
||||
parentSelected?: boolean
|
||||
): void {
|
||||
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
|
||||
const selected = parentSelected ?? isEntitySelected(this);
|
||||
drawController.setLineStyles(
|
||||
parentHighlighted ?? isEntityHighlighted(this),
|
||||
parentSelected ?? isEntitySelected(this),
|
||||
highlighted,
|
||||
selected,
|
||||
this.lineColor,
|
||||
this.lineWidth,
|
||||
this.lineDash
|
||||
);
|
||||
for (const edge of polygonToSegments(this.polygon)) {
|
||||
drawController.drawLine(edge.start, edge.end);
|
||||
// 테두리는 **집었을 때만** 그린다. 늘 그리면 도각의 로고·서명 자리에 흰 사각형이
|
||||
// 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02).
|
||||
if (highlighted || selected) {
|
||||
for (const edge of polygonToSegments(this.polygon)) {
|
||||
drawController.drawLine(edge.start, edge.end);
|
||||
}
|
||||
}
|
||||
|
||||
const width = this.polygon.box.width;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {LineEntity} from "./LineEntity.ts";
|
||||
import {TO_DEGREES} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { LineEntity } from './LineEntity.ts';
|
||||
import { TO_DEGREES } from '../App.consts.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
describe('getAngle', () => {
|
||||
it('should return 0 for a horizontal line', () => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import {type Box, Line, Point, Vector} from '@flatten-js/core'; // Added Box, Segment for completeness
|
||||
import {round} from 'es-toolkit'; // 1. Mocking for ../state.ts
|
||||
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest';
|
||||
import {EPSILON, MEASUREMENT_DECIMAL_PLACES, MEASUREMENT_FONT_SIZE, MEASUREMENT_LABEL_OFFSET,} from '../App.consts';
|
||||
import type {DrawController} from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
|
||||
import {isEntityHighlighted, isEntitySelected} from '../state.ts';
|
||||
import {MeasurementEntity} from './MeasurementEntity';
|
||||
import { type Box, Line, Point, Vector } from '@flatten-js/core'; // Added Box, Segment for completeness
|
||||
import { round } from 'es-toolkit'; // 1. Mocking for ../state.ts
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
import {
|
||||
EPSILON,
|
||||
MEASUREMENT_DECIMAL_PLACES,
|
||||
MEASUREMENT_FONT_SIZE,
|
||||
MEASUREMENT_LABEL_OFFSET,
|
||||
} from '../App.consts';
|
||||
import type { DrawController } from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
|
||||
import { isEntityHighlighted, isEntitySelected } from '../state.ts';
|
||||
import { MeasurementEntity } from './MeasurementEntity';
|
||||
|
||||
// 1. Mocking for ../state.ts
|
||||
vi.mock('../state.ts', () => ({
|
||||
|
||||
@@ -301,9 +301,7 @@ export class MeasurementEntity implements Entity {
|
||||
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
|
||||
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
|
||||
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
|
||||
);
|
||||
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
const originalTextDirection = normalUnit.rotate90CW();
|
||||
let finalTextDirection = originalTextDirection;
|
||||
if (
|
||||
@@ -418,9 +416,7 @@ export class MeasurementEntity implements Entity {
|
||||
];
|
||||
|
||||
// Calculate text properties
|
||||
const distance = String(
|
||||
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
|
||||
);
|
||||
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
|
||||
const worldFactor = annotationWorldFactor();
|
||||
const textHeight = getDimTextHeight() / worldFactor;
|
||||
// Estimate width: textString.length * fontSize * aspectRatioFactor
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { type Box, Point, Polygon } from '@flatten-js/core';
|
||||
|
||||
export function boxToPolygon(box: Box): Polygon {
|
||||
return new Polygon([
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
]);
|
||||
return new Polygon([
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
new Point(Math.min(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.max(box.low.y, box.high.y)),
|
||||
new Point(Math.max(box.low.x, box.high.x), Math.min(box.low.y, box.high.y)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function twoPointBoxToPolygon(first: Point, second: Point): Polygon {
|
||||
return new Polygon([
|
||||
new Point(Math.min(first.x, second.x), Math.min(first.y, second.y)),
|
||||
new Point(Math.min(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.min(first.y, second.y)),
|
||||
]);
|
||||
return new Polygon([
|
||||
new Point(Math.min(first.x, second.x), Math.min(first.y, second.y)),
|
||||
new Point(Math.min(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.max(first.y, second.y)),
|
||||
new Point(Math.max(first.x, second.x), Math.min(first.y, second.y)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ export function pasteFromClipboard(target?: Point): Entity[] {
|
||||
copy.lineDash = entity.lineDash;
|
||||
copy.layerId = entity.layerId;
|
||||
if (target) {
|
||||
copy.move(target.x - (clipboard as ClipboardContent).basePoint.x, target.y - (clipboard as ClipboardContent).basePoint.y);
|
||||
copy.move(
|
||||
target.x - (clipboard as ClipboardContent).basePoint.x,
|
||||
target.y - (clipboard as ClipboardContent).basePoint.y
|
||||
);
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
getAngleGuideOriginPoint,
|
||||
getAngleStep,
|
||||
getHoveredSnapPoints,
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getShouldDrawHelpers,
|
||||
getSnapTrackingEnabled,
|
||||
setAngleGuideEntities,
|
||||
setSnapPoint,
|
||||
setSnapPointOnAngleGuide,
|
||||
getAngleGuideOriginPoint,
|
||||
getAngleStep,
|
||||
getHoveredSnapPoints,
|
||||
getLayerById,
|
||||
getScreenCanvasDrawController,
|
||||
getShouldDrawHelpers,
|
||||
getSnapTrackingEnabled,
|
||||
setAngleGuideEntities,
|
||||
setSnapPoint,
|
||||
setSnapPointOnAngleGuide,
|
||||
} from '../state.ts';
|
||||
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
|
||||
import { getDrawHelpers } from './get-draw-guides.ts';
|
||||
@@ -19,42 +19,41 @@ import { compact } from 'es-toolkit';
|
||||
* Calculate angle guides and snap points
|
||||
*/
|
||||
export function calculateAngleGuidesAndSnapPoints() {
|
||||
const angleStep = getAngleStep();
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
const screenScale = screenCanvasDrawController.getScreenScale();
|
||||
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
|
||||
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
|
||||
// 잠금 레이어(b08-frame 등 참조용)는 스냅 대상에서 제외한다.
|
||||
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
|
||||
const entities = queryEntitiesNearPoint(
|
||||
worldMouseLocation.x,
|
||||
worldMouseLocation.y,
|
||||
maxSnapDistance * 2,
|
||||
).filter(entity => !getLayerById(entity.layerId)?.isLocked);
|
||||
const hoveredSnapPoints = getHoveredSnapPoints();
|
||||
const angleStep = getAngleStep();
|
||||
const screenCanvasDrawController = getScreenCanvasDrawController();
|
||||
const screenScale = screenCanvasDrawController.getScreenScale();
|
||||
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
|
||||
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
|
||||
// 잠금 레이어(b08-frame 등 참조용)는 스냅 대상에서 제외한다.
|
||||
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
|
||||
const entities = queryEntitiesNearPoint(
|
||||
worldMouseLocation.x,
|
||||
worldMouseLocation.y,
|
||||
maxSnapDistance * 2
|
||||
).filter((entity) => !getLayerById(entity.layerId)?.isLocked);
|
||||
const hoveredSnapPoints = getHoveredSnapPoints();
|
||||
|
||||
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
|
||||
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
hoveredSnapPoint =>
|
||||
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME,
|
||||
)
|
||||
: [];
|
||||
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
|
||||
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
|
||||
? hoveredSnapPoints.filter(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
|
||||
)
|
||||
: [];
|
||||
|
||||
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
|
||||
hoveredSnapPoint => hoveredSnapPoint.snapPoint.point,
|
||||
);
|
||||
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
|
||||
(hoveredSnapPoint) => hoveredSnapPoint.snapPoint.point
|
||||
);
|
||||
|
||||
if (getShouldDrawHelpers()) {
|
||||
const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers(
|
||||
entities,
|
||||
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
|
||||
worldMouseLocation,
|
||||
angleStep,
|
||||
maxSnapDistance,
|
||||
);
|
||||
setAngleGuideEntities(angleGuides);
|
||||
setSnapPoint(entitySnapPoint);
|
||||
setSnapPointOnAngleGuide(angleSnapPoint);
|
||||
}
|
||||
if (getShouldDrawHelpers()) {
|
||||
const { angleGuides, entitySnapPoint, angleSnapPoint } = getDrawHelpers(
|
||||
entities,
|
||||
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
|
||||
worldMouseLocation,
|
||||
angleStep,
|
||||
maxSnapDistance
|
||||
);
|
||||
setAngleGuideEntities(angleGuides);
|
||||
setSnapPoint(entitySnapPoint);
|
||||
setSnapPointOnAngleGuide(angleSnapPoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,167 +2,167 @@ import { describe, expect, it } from 'vitest';
|
||||
import { containRectangle } from './contain-rect.ts';
|
||||
|
||||
describe('containRectangle', () => {
|
||||
it('scales down a larger rectangle to fit into a smaller wrapper', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // contained: a 200x200 square
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // wrapper: a 100x100 square
|
||||
);
|
||||
// Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted,
|
||||
// but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100.
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
|
||||
});
|
||||
it('scales down a larger rectangle to fit into a smaller wrapper', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // contained: a 200x200 square
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100 // wrapper: a 100x100 square
|
||||
);
|
||||
// Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted,
|
||||
// but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100.
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 });
|
||||
});
|
||||
|
||||
it('scales up a smaller rectangle to fit inside a larger wrapper without exceeding boundaries', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // wrapper: 200x200
|
||||
);
|
||||
// Expected: scale up by factor of 4 to fill as much space as possible while containing
|
||||
// But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 });
|
||||
});
|
||||
it('scales up a smaller rectangle to fit inside a larger wrapper without exceeding boundaries', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200 // wrapper: 200x200
|
||||
);
|
||||
// Expected: scale up by factor of 4 to fill as much space as possible while containing
|
||||
// But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 200, maxY: 200 });
|
||||
});
|
||||
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is square', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50 square
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
100, // wrapper: 200x100
|
||||
);
|
||||
// Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2.
|
||||
// Min scale = 2, so final size = 100x100.
|
||||
// Center horizontally: (200 - 100)/2 = 50 offset, vertically: (100 - 100)/2=0 offset.
|
||||
// Result = (50,0) to (150,100)
|
||||
expect(result.minX).toBeCloseTo(50);
|
||||
expect(result.minY).toBeCloseTo(0);
|
||||
expect(result.maxX).toBeCloseTo(150);
|
||||
expect(result.maxY).toBeCloseTo(100);
|
||||
});
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is square', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
50,
|
||||
50, // contained: 50x50 square
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
100 // wrapper: 200x100
|
||||
);
|
||||
// Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2.
|
||||
// Min scale = 2, so final size = 100x100.
|
||||
// Center horizontally: (200 - 100)/2 = 50 offset, vertically: (100 - 100)/2=0 offset.
|
||||
// Result = (50,0) to (150,100)
|
||||
expect(result.minX).toBeCloseTo(50);
|
||||
expect(result.minY).toBeCloseTo(0);
|
||||
expect(result.maxX).toBeCloseTo(150);
|
||||
expect(result.maxY).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is also rectangular', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
50, // contained: 200x50
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
100, // wrapper: 300x100
|
||||
);
|
||||
// Contained AR = 200/50 = 4:1
|
||||
// Wrapper AR = 300/100 = 3:1
|
||||
// To fit inside 300x100:
|
||||
// Scale factors: width scale = 300/200=1.5, height scale=100/50=2.
|
||||
// min scale = 1.5
|
||||
// Final size: 200*1.5=300 width, 50*1.5=75 height
|
||||
// Center vertically: (100 - 75)/2=12.5 offset, horizontally just fits width fully
|
||||
expect(result).toEqual({ minX: 0, minY: 12.5, maxX: 300, maxY: 87.5 });
|
||||
});
|
||||
it('maintains aspect ratio when wrapper is rectangular and contained is also rectangular', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
50, // contained: 200x50
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
100 // wrapper: 300x100
|
||||
);
|
||||
// Contained AR = 200/50 = 4:1
|
||||
// Wrapper AR = 300/100 = 3:1
|
||||
// To fit inside 300x100:
|
||||
// Scale factors: width scale = 300/200=1.5, height scale=100/50=2.
|
||||
// min scale = 1.5
|
||||
// Final size: 200*1.5=300 width, 50*1.5=75 height
|
||||
// Center vertically: (100 - 75)/2=12.5 offset, horizontally just fits width fully
|
||||
expect(result).toEqual({ minX: 0, minY: 12.5, maxX: 300, maxY: 87.5 });
|
||||
});
|
||||
|
||||
it('handles zero-width/height contained rectangle gracefully', () => {
|
||||
// Contained rectangle is essentially a line or point
|
||||
const result = containRectangle(
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
10, // contained has 0 width/height
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200, // wrapper
|
||||
);
|
||||
// Center as a single point at (100,100)
|
||||
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
|
||||
});
|
||||
it('handles zero-width/height contained rectangle gracefully', () => {
|
||||
// Contained rectangle is essentially a line or point
|
||||
const result = containRectangle(
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
10, // contained has 0 width/height
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
200 // wrapper
|
||||
);
|
||||
// Center as a single point at (100,100)
|
||||
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
|
||||
});
|
||||
|
||||
it('does not scale if contained rectangle already fits', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // contained fits easily
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
300, // wrapper
|
||||
);
|
||||
// Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3.
|
||||
// But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding,
|
||||
// So final size is 300x300, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 300, maxY: 300 });
|
||||
});
|
||||
it('does not scale if contained rectangle already fits', () => {
|
||||
const result = containRectangle(
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // contained fits easily
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
300 // wrapper
|
||||
);
|
||||
// Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3.
|
||||
// But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding,
|
||||
// So final size is 300x300, centered at (0,0).
|
||||
expect(result).toEqual({ minX: 0, minY: 0, maxX: 300, maxY: 300 });
|
||||
});
|
||||
|
||||
it('correctly centers when wrapper and contained have different origins', () => {
|
||||
const result = containRectangle(
|
||||
5,
|
||||
5,
|
||||
15,
|
||||
35, // contained: 10 wide x 30 tall
|
||||
10,
|
||||
20,
|
||||
110,
|
||||
220, // wrapper: 100x200
|
||||
);
|
||||
// Wrapper size: 100x200
|
||||
// Contained size: 10x30
|
||||
// Scale factors: width scale = 100/10=10, height scale=200/30 ≈ 6.666...
|
||||
// min scale = 6.666...
|
||||
// Final size: width = 10 * 6.666... ≈ 66.666..., height = 30 * 6.666... ≈ 200
|
||||
// After scaling, top-left corner should be placed so it centers:
|
||||
// Horizontal center: (100 - 66.666...)/2 = 16.666... offset from wrapperMinX=10 => minX≈26.666...
|
||||
// Vertical center: fits height exactly, so minY=20, maxY=20+200=220
|
||||
expect(result.minX).toBeCloseTo(26.6667);
|
||||
expect(result.minY).toBeCloseTo(20);
|
||||
expect(result.maxX).toBeCloseTo(93.3333);
|
||||
expect(result.maxY).toBeCloseTo(220);
|
||||
});
|
||||
it('correctly centers when wrapper and contained have different origins', () => {
|
||||
const result = containRectangle(
|
||||
5,
|
||||
5,
|
||||
15,
|
||||
35, // contained: 10 wide x 30 tall
|
||||
10,
|
||||
20,
|
||||
110,
|
||||
220 // wrapper: 100x200
|
||||
);
|
||||
// Wrapper size: 100x200
|
||||
// Contained size: 10x30
|
||||
// Scale factors: width scale = 100/10=10, height scale=200/30 ≈ 6.666...
|
||||
// min scale = 6.666...
|
||||
// Final size: width = 10 * 6.666... ≈ 66.666..., height = 30 * 6.666... ≈ 200
|
||||
// After scaling, top-left corner should be placed so it centers:
|
||||
// Horizontal center: (100 - 66.666...)/2 = 16.666... offset from wrapperMinX=10 => minX≈26.666...
|
||||
// Vertical center: fits height exactly, so minY=20, maxY=20+200=220
|
||||
expect(result.minX).toBeCloseTo(26.6667);
|
||||
expect(result.minY).toBeCloseTo(20);
|
||||
expect(result.maxX).toBeCloseTo(93.3333);
|
||||
expect(result.maxY).toBeCloseTo(220);
|
||||
});
|
||||
|
||||
it('handles negative coordinates in wrapper and contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
-100,
|
||||
-50,
|
||||
100,
|
||||
50, // wrapper: 200 wide x 100 tall
|
||||
);
|
||||
// Scale factors: width scale = 200/100=2, height scale=100/50=2
|
||||
// min scale = 2, final size: 200x100 exactly.
|
||||
// Centering: wrapper ranges from -100 to 100 (x) and -50 to 50 (y)
|
||||
// After scaling contained to 200x100, it fits exactly. minX = -100, maxX=100, minY=-50, maxY=50
|
||||
expect(result).toEqual({ minX: -100, minY: -50, maxX: 100, maxY: 50 });
|
||||
});
|
||||
it('handles negative coordinates in wrapper and contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
-100,
|
||||
-50,
|
||||
100,
|
||||
50 // wrapper: 200 wide x 100 tall
|
||||
);
|
||||
// Scale factors: width scale = 200/100=2, height scale=100/50=2
|
||||
// min scale = 2, final size: 200x100 exactly.
|
||||
// Centering: wrapper ranges from -100 to 100 (x) and -50 to 50 (y)
|
||||
// After scaling contained to 200x100, it fits exactly. minX = -100, maxX=100, minY=-50, maxY=50
|
||||
expect(result).toEqual({ minX: -100, minY: -50, maxX: 100, maxY: 50 });
|
||||
});
|
||||
|
||||
it('handles negative coordinates in contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100, // wrapper: 100 wide x 100 tall
|
||||
);
|
||||
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
|
||||
});
|
||||
it('handles negative coordinates in contained rectangles', () => {
|
||||
const result = containRectangle(
|
||||
-50,
|
||||
-25,
|
||||
50,
|
||||
25, // contained: 100 wide x 50 tall
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100 // wrapper: 100 wide x 100 tall
|
||||
);
|
||||
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,49 @@
|
||||
export function containRectangle(
|
||||
containedRectMinX: number,
|
||||
containedRectMinY: number,
|
||||
containedRectMaxX: number,
|
||||
containedRectMaxY: number,
|
||||
wrapperRectMinX: number,
|
||||
wrapperRectMinY: number,
|
||||
wrapperRectMaxX: number,
|
||||
wrapperRectMaxY: number,
|
||||
containedRectMinX: number,
|
||||
containedRectMinY: number,
|
||||
containedRectMaxX: number,
|
||||
containedRectMaxY: number,
|
||||
wrapperRectMinX: number,
|
||||
wrapperRectMinY: number,
|
||||
wrapperRectMaxX: number,
|
||||
wrapperRectMaxY: number
|
||||
): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
// Calculate the width and height of the wrapper rectangle
|
||||
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
|
||||
const wrapperHeight = wrapperRectMaxY - wrapperRectMinY;
|
||||
// Calculate the width and height of the wrapper rectangle
|
||||
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
|
||||
const wrapperHeight = wrapperRectMaxY - wrapperRectMinY;
|
||||
|
||||
// Calculate the width and height of the contained rectangle
|
||||
const containedWidth = containedRectMaxX - containedRectMinX;
|
||||
const containedHeight = containedRectMaxY - containedRectMinY;
|
||||
// Calculate the width and height of the contained rectangle
|
||||
const containedWidth = containedRectMaxX - containedRectMinX;
|
||||
const containedHeight = containedRectMaxY - containedRectMinY;
|
||||
|
||||
// Edge case: if contained dimensions are zero, just center as a point
|
||||
if (containedWidth === 0 || containedHeight === 0) {
|
||||
const centerX = wrapperRectMinX + wrapperWidth / 2;
|
||||
const centerY = wrapperRectMinY + wrapperHeight / 2;
|
||||
return {
|
||||
minX: centerX,
|
||||
minY: centerY,
|
||||
maxX: centerX,
|
||||
maxY: centerY,
|
||||
};
|
||||
}
|
||||
// Edge case: if contained dimensions are zero, just center as a point
|
||||
if (containedWidth === 0 || containedHeight === 0) {
|
||||
const centerX = wrapperRectMinX + wrapperWidth / 2;
|
||||
const centerY = wrapperRectMinY + wrapperHeight / 2;
|
||||
return {
|
||||
minX: centerX,
|
||||
minY: centerY,
|
||||
maxX: centerX,
|
||||
maxY: centerY,
|
||||
};
|
||||
}
|
||||
|
||||
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
wrapperWidth / containedWidth,
|
||||
wrapperHeight / containedHeight,
|
||||
);
|
||||
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
|
||||
const scale = Math.min(wrapperWidth / containedWidth, wrapperHeight / containedHeight);
|
||||
|
||||
// Compute final displayed dimensions
|
||||
const displayWidth = containedWidth * scale;
|
||||
const displayHeight = containedHeight * scale;
|
||||
// Compute final displayed dimensions
|
||||
const displayWidth = containedWidth * scale;
|
||||
const displayHeight = containedHeight * scale;
|
||||
|
||||
// Compute offsets to center the scaled rectangle
|
||||
const offsetX = wrapperRectMinX + (wrapperWidth - displayWidth) / 2;
|
||||
const offsetY = wrapperRectMinY + (wrapperHeight - displayHeight) / 2;
|
||||
// Compute offsets to center the scaled rectangle
|
||||
const offsetX = wrapperRectMinX + (wrapperWidth - displayWidth) / 2;
|
||||
const offsetY = wrapperRectMinY + (wrapperHeight - displayHeight) / 2;
|
||||
|
||||
// Return the final coordinates of the scaled and centered rectangle
|
||||
return {
|
||||
minX: offsetX,
|
||||
minY: offsetY,
|
||||
maxX: offsetX + displayWidth,
|
||||
maxY: offsetY + displayHeight,
|
||||
};
|
||||
// Return the final coordinates of the scaled and centered rectangle
|
||||
return {
|
||||
minX: offsetX,
|
||||
minY: offsetY,
|
||||
maxX: offsetX + displayWidth,
|
||||
maxY: offsetY + displayHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,17 +5,13 @@ describe('svgPathToSegments', () => {
|
||||
it('should handle simple move and line commands', () => {
|
||||
const path = 'M 10 10 L 20 20';
|
||||
const segments = svgPathToSegments(path);
|
||||
expect(segments).toEqual([
|
||||
{ x1: 10, y1: 10, x2: 20, y2: 20 },
|
||||
]);
|
||||
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
|
||||
});
|
||||
|
||||
it('should handle relative line commands', () => {
|
||||
const path = 'M 10 10 l 10 10';
|
||||
const segments = svgPathToSegments(path);
|
||||
expect(segments).toEqual([
|
||||
{ x1: 10, y1: 10, x2: 20, y2: 20 },
|
||||
]);
|
||||
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
|
||||
});
|
||||
|
||||
it('should handle horizontal and vertical lines', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {toast} from 'react-toastify';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// A small type alias for clarity.
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
getHighlightedEntityIds,
|
||||
getLayers,
|
||||
getSelectedEntityIds,
|
||||
getScreenCanvasDrawController,
|
||||
getSnapPoint,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
} from '../state';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { isEntityHidden } from './visibility';
|
||||
|
||||
export function registerCadDebugHook(): void {
|
||||
@@ -45,5 +47,10 @@ export function registerCadDebugHook(): void {
|
||||
readOnly: () => isDrawingReadOnly(),
|
||||
dirty: () => isDrawingDirty(),
|
||||
meta: () => getDesignMeta(),
|
||||
// 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다.
|
||||
screen: (x: number, y: number) => {
|
||||
const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y));
|
||||
return { x: point.x, y: point.y };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {EntityName} from '../entities/Entity.ts';
|
||||
import type {JsonDrawingFileSerialized} from './import-export-handlers/export-entities-to-json.ts';
|
||||
import { EntityName } from '../entities/Entity.ts';
|
||||
import type { JsonDrawingFileSerialized } from './import-export-handlers/export-entities-to-json.ts';
|
||||
|
||||
export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
|
||||
entities: [
|
||||
@@ -33,8 +33,9 @@ export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
|
||||
},
|
||||
radius: 156.92367603040066,
|
||||
startAngle: 0,
|
||||
// endAngle: (2 * Math.PI * 3) / 4,
|
||||
endAngle: 1.5707963267948966,
|
||||
// 클릭점(393,1108)이 호 위(중심각 ≈145°)에 놓이도록 3/4바퀴를 쓴다 —
|
||||
// 90°까지만 돌면 호가 클릭점에서 147px 떨어져 직선(64px)이 더 가깝다.
|
||||
endAngle: (2 * Math.PI * 3) / 4,
|
||||
counterClockwise: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Point} from "@flatten-js/core";
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {findClosestEntity} from './find-closest-entity';
|
||||
import {arcAndLineEntitiesMock} from "./find-closest-entity.mocks.ts";
|
||||
import {getEntitiesAndLayersFromJsonObject,} from './import-export-handlers/import-entities-from-json.ts';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findClosestEntity } from './find-closest-entity';
|
||||
import { arcAndLineEntitiesMock } from './find-closest-entity.mocks.ts';
|
||||
import { getEntitiesAndLayersFromJsonObject } from './import-export-handlers/import-entities-from-json.ts';
|
||||
|
||||
describe('findClosestEntity', () => {
|
||||
it('should return the arc as the closest entity', async () => {
|
||||
|
||||
@@ -11,34 +11,28 @@ import { sortPointsOnArc } from './sort-points-on-arc';
|
||||
* @param pointsOnShape
|
||||
*/
|
||||
export function findNeighboringPointsOnArc(
|
||||
clickedPointOnShape: Point,
|
||||
arc: ArcEntity,
|
||||
pointsOnShape: Point[],
|
||||
clickedPointOnShape: Point,
|
||||
arc: ArcEntity,
|
||||
pointsOnShape: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnArc(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(arc.getShape() as Arc).center,
|
||||
(arc.getShape() as Arc).start,
|
||||
);
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnArc(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(arc.getShape() as Arc).center,
|
||||
(arc.getShape() as Arc).start
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnShape, point),
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnArc',
|
||||
);
|
||||
}
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnShape, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnArc');
|
||||
}
|
||||
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
|
||||
],
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
|
||||
],
|
||||
];
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,33 +11,27 @@ import { sortPointsOnCircle } from './sort-points-on-circle';
|
||||
* @param pointsOnShape
|
||||
*/
|
||||
export function findNeighboringPointsOnCircle(
|
||||
clickedPointOnShape: Point,
|
||||
circle: CircleEntity,
|
||||
pointsOnShape: Point[],
|
||||
clickedPointOnShape: Point,
|
||||
circle: CircleEntity,
|
||||
pointsOnShape: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnCircle(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(circle.getShape() as Circle).center,
|
||||
);
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortPointsOnCircle(
|
||||
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
|
||||
(circle.getShape() as Circle).center
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnShape, point),
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnCircle',
|
||||
);
|
||||
}
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnShape, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnCircle');
|
||||
}
|
||||
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
|
||||
],
|
||||
sortedPoints[
|
||||
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
|
||||
],
|
||||
];
|
||||
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
|
||||
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
|
||||
return [
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
|
||||
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,31 +11,26 @@ import { pointDistance } from './distance-between-points';
|
||||
* @param pointsOnLine
|
||||
*/
|
||||
export function findNeighboringPointsOnLine(
|
||||
clickedPointOnLine: Point,
|
||||
lineStartPoint: Point,
|
||||
lineEndPoint: Point,
|
||||
pointsOnLine: Point[],
|
||||
clickedPointOnLine: Point,
|
||||
lineStartPoint: Point,
|
||||
lineEndPoint: Point,
|
||||
pointsOnLine: Point[]
|
||||
): [Point, Point] {
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortBy(
|
||||
uniqWith(
|
||||
[lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint],
|
||||
isPointEqual,
|
||||
),
|
||||
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)],
|
||||
);
|
||||
// Sort points from start point to endpoint
|
||||
const sortedPoints = sortBy(
|
||||
uniqWith([lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint], isPointEqual),
|
||||
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)]
|
||||
);
|
||||
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
|
||||
isPointEqual(clickedPointOnLine, point),
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error(
|
||||
'Clicked point not found on line in function findNeighboringPointsOnLine',
|
||||
);
|
||||
}
|
||||
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
|
||||
isPointEqual(clickedPointOnLine, point)
|
||||
);
|
||||
if (indexOfClickedPoint === -1) {
|
||||
throw new Error('Clicked point not found on line in function findNeighboringPointsOnLine');
|
||||
}
|
||||
|
||||
return [
|
||||
sortedPoints[indexOfClickedPoint - 1] || lineStartPoint,
|
||||
sortedPoints[indexOfClickedPoint + 1] || lineEndPoint,
|
||||
];
|
||||
return [
|
||||
sortedPoints[indexOfClickedPoint - 1] || lineStartPoint,
|
||||
sortedPoints[indexOfClickedPoint + 1] || lineEndPoint,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE
|
||||
while (remaining.length) {
|
||||
const tail = loop[loop.length - 1];
|
||||
const index = remaining.findIndex(
|
||||
(chain) =>
|
||||
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
(chain) => near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
|
||||
);
|
||||
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
|
||||
const [chain] = remaining.splice(index, 1);
|
||||
|
||||
@@ -33,7 +33,10 @@ function sampleCircle(circle: Circle, segments: number): Point[] {
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = (2 * Math.PI * index) / segments;
|
||||
points.push(
|
||||
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
|
||||
new Point(
|
||||
circle.center.x + circle.r * Math.cos(angle),
|
||||
circle.center.y + circle.r * Math.sin(angle)
|
||||
)
|
||||
);
|
||||
}
|
||||
return points;
|
||||
@@ -48,9 +51,7 @@ export function dedupeConsecutive(points: Point[]): Point[] {
|
||||
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
|
||||
if (entity.getType() === EntityName.PolyLine) {
|
||||
const children = (entity as PolyLineEntity).getEntities();
|
||||
return dedupeConsecutive(
|
||||
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
|
||||
);
|
||||
return dedupeConsecutive(children.flatMap((child) => sampleEntityPoints(child, curveSegments)));
|
||||
}
|
||||
|
||||
const shape = entity.getShape();
|
||||
|
||||
@@ -71,7 +71,9 @@ export function regularPolygonPoints(center: Point, vertex: Point, sides: number
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const angle = startAngle + (2 * Math.PI * index) / count;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
points.push(
|
||||
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
points.push(points[0].clone());
|
||||
return points;
|
||||
@@ -101,11 +103,7 @@ export function ellipsePoints(
|
||||
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
|
||||
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
|
||||
if (controlPoints.length < 3) return [...controlPoints];
|
||||
const extended = [
|
||||
controlPoints[0],
|
||||
...controlPoints,
|
||||
controlPoints[controlPoints.length - 1],
|
||||
];
|
||||
const extended = [controlPoints[0], ...controlPoints, controlPoints[controlPoints.length - 1]];
|
||||
const result: Point[] = [];
|
||||
for (let index = 1; index < extended.length - 2; index++) {
|
||||
const p0 = extended[index - 1];
|
||||
@@ -158,7 +156,9 @@ function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
|
||||
const points: Point[] = [];
|
||||
for (let index = 0; index <= segments; index++) {
|
||||
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
|
||||
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
|
||||
points.push(
|
||||
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
|
||||
);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import {LineEntity} from '../entities/LineEntity';
|
||||
import {times} from './times';
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH} from "../App.consts.ts";
|
||||
import {getActiveLayerId} from "../state.ts";
|
||||
import { LineEntity } from '../entities/LineEntity';
|
||||
import { times } from './times';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH } from '../App.consts.ts';
|
||||
import { getActiveLayerId } from '../state.ts';
|
||||
|
||||
export function getAngleGuideLines(
|
||||
firstPoint: Point,
|
||||
angleStep: number,
|
||||
): LineEntity[] {
|
||||
// Only for 180 degrees since we draw lines that are infinite in both directions,
|
||||
// so we only need to fill half a circle to fill the complete circle
|
||||
return times(180 / angleStep, i => {
|
||||
const angle = i * angleStep;
|
||||
const angleRad = angle * (Math.PI / 180);
|
||||
const x = firstPoint.x + Math.cos(angleRad);
|
||||
const y = firstPoint.y + Math.sin(angleRad);
|
||||
const angleLine = new LineEntity(getActiveLayerId(),
|
||||
new Point(
|
||||
firstPoint.x - 10000 * (x - firstPoint.x),
|
||||
firstPoint.y - 10000 * (y - firstPoint.y),
|
||||
),
|
||||
new Point(
|
||||
firstPoint.x + 10000 * (x - firstPoint.x),
|
||||
firstPoint.y + 10000 * (y - firstPoint.y),
|
||||
),
|
||||
);
|
||||
angleLine.lineColor = ANGLE_GUIDES_COLOR;
|
||||
angleLine.lineDash = ANGLE_GUIDES_DASH;
|
||||
return angleLine
|
||||
});
|
||||
export function getAngleGuideLines(firstPoint: Point, angleStep: number): LineEntity[] {
|
||||
// Only for 180 degrees since we draw lines that are infinite in both directions,
|
||||
// so we only need to fill half a circle to fill the complete circle
|
||||
return times(180 / angleStep, (i) => {
|
||||
const angle = i * angleStep;
|
||||
const angleRad = angle * (Math.PI / 180);
|
||||
const x = firstPoint.x + Math.cos(angleRad);
|
||||
const y = firstPoint.y + Math.sin(angleRad);
|
||||
const angleLine = new LineEntity(
|
||||
getActiveLayerId(),
|
||||
new Point(
|
||||
firstPoint.x - 10000 * (x - firstPoint.x),
|
||||
firstPoint.y - 10000 * (y - firstPoint.y)
|
||||
),
|
||||
new Point(
|
||||
firstPoint.x + 10000 * (x - firstPoint.x),
|
||||
firstPoint.y + 10000 * (y - firstPoint.y)
|
||||
)
|
||||
);
|
||||
angleLine.lineColor = ANGLE_GUIDES_COLOR;
|
||||
angleLine.lineDash = ANGLE_GUIDES_DASH;
|
||||
return angleLine;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Point} from '@flatten-js/core';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {TO_DEGREES} from '../App.consts.ts';
|
||||
import {getAngleWithXAxis} from './get-angle-with-x-axis.ts';
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TO_DEGREES } from '../App.consts.ts';
|
||||
import { getAngleWithXAxis } from './get-angle-with-x-axis.ts';
|
||||
|
||||
describe('getAngleWithXAxis', () => {
|
||||
it('should return 90 degrees in radians', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
|
||||
export function getAngleWithXAxis(start: Point, end: Point): number {
|
||||
const dx = end.x - start.x;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Entity} from "../entities/Entity.ts";
|
||||
import type { Entity } from '../entities/Entity.ts';
|
||||
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {type SnapPoint, SnapPointType} from '../App.types';
|
||||
import {pointDistance} from './distance-between-points';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { type SnapPoint, SnapPointType } from '../App.types';
|
||||
import { pointDistance } from './distance-between-points';
|
||||
|
||||
// /**
|
||||
// * Some points need to take priority over others when snapping to them. This multiplier is used to give a higher score to the points that should take priority
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type {Point} from '@flatten-js/core';
|
||||
import {compact} from 'es-toolkit';
|
||||
import {SNAP_ANGLE_DISTANCE} from '../App.consts';
|
||||
import {type SnapPoint, SnapPointType} from '../App.types';
|
||||
import type {Entity} from '../entities/Entity';
|
||||
import type {LineEntity} from '../entities/LineEntity';
|
||||
import {findClosestEntity} from './find-closest-entity';
|
||||
import {getAngleGuideLines} from './get-angle-guide-lines';
|
||||
import {getClosestSnapPointWithinRadius} from './get-closest-snap-point';
|
||||
import {getIntersectionPoints} from './get-intersection-points';
|
||||
import type { Point } from '@flatten-js/core';
|
||||
import { compact } from 'es-toolkit';
|
||||
import { SNAP_ANGLE_DISTANCE } from '../App.consts';
|
||||
import { type SnapPoint, SnapPointType } from '../App.types';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import type { LineEntity } from '../entities/LineEntity';
|
||||
import { findClosestEntity } from './find-closest-entity';
|
||||
import { getAngleGuideLines } from './get-angle-guide-lines';
|
||||
import { getClosestSnapPointWithinRadius } from './get-closest-snap-point';
|
||||
import { getIntersectionPoints } from './get-intersection-points';
|
||||
|
||||
/**
|
||||
* Gets the angle guides from the angle point to the mouse if the mouse is close to one of the angle steps and also returns the closest snap point
|
||||
|
||||
@@ -3,20 +3,20 @@ import type { Point } from '@flatten-js/core';
|
||||
|
||||
// TODO in the future we could optimize this by only calculating intersection points near the mouse
|
||||
export function getIntersectionPoints(entities: Entity[]): Point[] {
|
||||
const intersectionPoints: Point[] = [];
|
||||
const intersectionPoints: Point[] = [];
|
||||
|
||||
// Calculate all intersections between all entities
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity1 = entities[i];
|
||||
for (let j = i; j < entities.length; j++) {
|
||||
// intersections are symmetric, so we only need to calculate them in one direction (let j = i)
|
||||
// Calculate all intersections between all entities
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const entity1 = entities[i];
|
||||
for (let j = i; j < entities.length; j++) {
|
||||
// intersections are symmetric, so we only need to calculate them in one direction (let j = i)
|
||||
|
||||
if (i === j) continue; // Do not check for intersections with yourself
|
||||
if (i === j) continue; // Do not check for intersections with yourself
|
||||
|
||||
const entity2 = entities[j];
|
||||
intersectionPoints.push(...entity1.getIntersections(entity2));
|
||||
}
|
||||
}
|
||||
const entity2 = entities[j];
|
||||
intersectionPoints.push(...entity1.getIntersections(entity2));
|
||||
}
|
||||
}
|
||||
|
||||
return intersectionPoints;
|
||||
return intersectionPoints;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type Point, Vector} from '@flatten-js/core';
|
||||
import { type Point, Vector } from '@flatten-js/core';
|
||||
import {
|
||||
type AbsolutePointInputEvent,
|
||||
ActorEvent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Point } from '@flatten-js/core';
|
||||
|
||||
export interface PointWithAngle {
|
||||
point: Point;
|
||||
angle: number;
|
||||
point: Point;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user