sync: 4환경 수렴본 main 반영 (PR #10)

4환경 브랜치 전부 fa0624a7 / tree 95d6e89b 동일. 커밋 34건. 테스트 359 passed, 17 skipped, 0 failed.
This commit was merged in pull request #10.
This commit is contained in:
2026-09-02 17:24:18 +09:00
208 changed files with 8086 additions and 6501 deletions
+10
View File
@@ -0,0 +1,10 @@
# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면
# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫.
B07_DesignDetail/openwebcad/
# 빌드·산출물·가상환경 — 포맷 대상이 아니다.
dist/
venv/
storage/
tmp/
graphify-out/
+64 -2
View File
@@ -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 }) });
}
+40 -30
View File
@@ -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
+136 -4
View File
@@ -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)
+21
View File
@@ -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);
}
+110 -16
View File
@@ -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 {
+66
View File
@@ -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 {
+45 -67
View File
@@ -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);
}
+42 -126
View File
@@ -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");
+2 -6
View File
@@ -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);
}
+5 -31
View File
@@ -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 };
});
+12 -46
View File
@@ -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),
)
+10 -245
View File
@@ -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)
+2 -233
View File
@@ -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": "설계 초기화 처리 중 오류가 발생했습니다."},
)
+256
View File
@@ -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": "설계 초기화 처리 중 오류가 발생했습니다."},
)
+25 -2
View File
@@ -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
? {
+14 -93
View File
@@ -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[];
}
+6 -1
View File
@@ -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;
}
+19 -5
View File
@@ -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;
}
+28 -1
View File
@@ -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" : "",
];
}
+4 -63
View File
@@ -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[];
}
+27 -167
View File
@@ -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,
};
/*
* .
+216
View File
@@ -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();
}
}
+56 -147
View File
@@ -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);
},
};
}
+15 -483
View File
@@ -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> {
+492
View File
@@ -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;
}
+48 -294
View File
@@ -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>;
+231
View File
@@ -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 => {
+10 -31
View File
@@ -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 {})
+95 -2
View File
@@ -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;
}
+29 -92
View File
@@ -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