diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..2e94d179 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면 +# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫. +B07_DesignDetail/openwebcad/ + +# 빌드·산출물·가상환경 — 포맷 대상이 아니다. +dist/ +venv/ +storage/ +tmp/ +graphify-out/ diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 7e9a782a..1d55c657 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -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 { }); } -export async function fetchCompanyMembers(): Promise { - 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 { + const data = await request<{ members: Member[] }>( + `/dashboard/admin/members${companyQuery(companyId)}`, + ); return data.members; } +export async function fetchCompanyAssets(companyId?: number | null): Promise { + const data = await request<{ assets: CompanyAsset[] }>( + `/dashboard/company/assets${companyQuery(companyId)}`, + ); + return data.assets; +} + +/** multipart 업로드라 `request()`의 JSON 헤더를 쓰지 않는다. */ +export async function createCompanyAsset(form: FormData): Promise { + 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 { + 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 { return request("/dashboard/admin/members", { method: "POST", body: body({ email }) }); } diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 1132aec2..b78a2d69 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -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, ), ) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Assets.py b/B01_Dashboard/B01_Dashboard_Repository_Assets.py new file mode 100644 index 00000000..c7fbe130 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Assets.py @@ -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 diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index b67ad343..46ad1b6d 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index de9e931c..d5d40b6f 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -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) diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts new file mode 100644 index 00000000..2f43dd9d --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -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); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index d0f941a5..9d114aae 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -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 { 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 { diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 00877848..cd70421d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -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 { diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index 27265301..e07dbf98 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -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(response); } finally { window.clearTimeout(timeoutId); @@ -92,22 +89,19 @@ export async function createUploadSession( completeUpload = false, lasFree = false, ): Promise { - 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(response); } @@ -138,21 +132,18 @@ export async function finalizeUploadSession( fingerprint?: string | null, lasFree = false, ): Promise { - 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(response); } @@ -160,13 +151,10 @@ export async function fetchUploadStatus( projectId: string, sessionId: string, ): Promise { - 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(response); } @@ -200,16 +188,11 @@ export interface UploadOverviewResponse { } /** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */ -export async function fetchUploadOverview( - projectId: string, -): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${projectId}/upload-overview`, - { - method: "GET", - credentials: "include", - }, - ); +export async function fetchUploadOverview(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, { + method: "GET", + credentials: "include", + }); return await readJsonOrThrow(response); } @@ -223,15 +206,10 @@ export interface WF1AnalysisStatus { error?: string; } -export async function checkWF1AnalysisStatus( - projectId: string, -): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${projectId}/surface/status`, - { - method: "GET", - credentials: "include", - }, - ); +export async function checkWF1AnalysisStatus(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, { + method: "GET", + credentials: "include", + }); return await readJsonOrThrow(response); } diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 463629f7..9ed8384c 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -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 { 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 { const cssState = stateName === "failed" ? "error" : stateName; card.classList.add(`b03-file__card--${cssState}`); - const badgeContainer = card.querySelector( - ".b03-file__card-badge-container", - ); + const badgeContainer = card.querySelector(".b03-file__card-badge-container"); if (badgeContainer) { badgeContainer.replaceChildren(); if (stateName === "empty") { @@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (!state || !card) return; renderExtensionLabel(card, state); - const fileName = card.querySelector( - ".b03-file__file-name", - ); - const fileSize = card.querySelector( - ".b03-file__file-size", - ); - const progress = card.querySelector( - ".b03-file__progress-bar", - ); - const progressBytes = card.querySelector( - ".b03-file__progress-bytes", - ); - const progressSpeed = card.querySelector( - ".b03-file__progress-speed", - ); - const progressEta = card.querySelector( - ".b03-file__progress-eta", - ); - const error = card.querySelector( - ".b03-file__error-message", - ); - const remove = card.querySelector( - ".b03-file__card-remove", - ); + const fileName = card.querySelector(".b03-file__file-name"); + const fileSize = card.querySelector(".b03-file__file-size"); + const progress = card.querySelector(".b03-file__progress-bar"); + const progressBytes = card.querySelector(".b03-file__progress-bytes"); + const progressSpeed = card.querySelector(".b03-file__progress-speed"); + const progressEta = card.querySelector(".b03-file__progress-eta"); + const error = card.querySelector(".b03-file__error-message"); + const remove = card.querySelector(".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 { 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 { 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 { + async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { 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 { 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 { 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 { * 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 { // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(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 { : "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 { 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( - ".b03-file__slot-input", - )!; + const input = card.querySelector(".b03-file__slot-input")!; input.accept = state.extensions.join(","); - const select = card.querySelector( - ".b03-file__card-select", - )!; + const select = card.querySelector(".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( - ".b03-file__card-remove", - )!; + const remove = card.querySelector(".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 { 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 { 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 { } } - async function startChunkedUpload( - targetStates = selectedStates(), - ): Promise { + async function startChunkedUpload(targetStates = selectedStates()): Promise { if (isUploading) return; // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. if (tempPicker.selected()) { @@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { 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 { 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 { 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 { 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 { 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"); diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 550b1ed7..c0f8768f 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -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); } diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index ea3502f9..4ee67cc8 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -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 }; }); diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 7f018a6c..dafe030b 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -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 { +export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise { 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); diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 39819486..75b7c565 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -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; + showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise; 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); diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index b8db30eb..87cdae81 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -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), ) diff --git a/B05_Profile/B05_Profile_Engine_RidgeValley.py b/B05_Profile/B05_Profile_Engine_RidgeValley.py index c44bed63..eaa53da9 100644 --- a/B05_Profile/B05_Profile_Engine_RidgeValley.py +++ b/B05_Profile/B05_Profile_Engine_RidgeValley.py @@ -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], diff --git a/B05_Profile/B05_Profile_Engine_RidgeValley_Graph.py b/B05_Profile/B05_Profile_Engine_RidgeValley_Graph.py new file mode 100644 index 00000000..e3ba5390 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RidgeValley_Graph.py @@ -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) diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 4674064a..3f0a47c9 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -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": "설계 초기화 처리 중 오류가 발생했습니다."}, - ) diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py new file mode 100644 index 00000000..1e087863 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -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": "설계 초기화 처리 중 오류가 발생했습니다."}, + ) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index c0ffb7de..0541eadc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 ? { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index cc37c22d..06890650 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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 포켓에 되메운다 — 다단이면 `성토 > 구조물 > 성토`라 성토선이 diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts b/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts new file mode 100644 index 00000000..dce9f83f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Build_Types.ts @@ -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[]; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts index 76b61438..359f937c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -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 = []; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts index a16b9bf3..534cf72b 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts @@ -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, - 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, - 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; -} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts index 07f4fc5d..42b90c52 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -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; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index ca4757d2..a6e39ba3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -50,6 +50,26 @@ const PLAN_CURVE_COLORS: Record = { "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 사용자). 성토면과 같은 색·빗금이라 diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index dc079a94..230aef90 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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 { - // 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 — - // 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28). - const extras: Array = []; - 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"; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts new file mode 100644 index 00000000..8569f157 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -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 { + // 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 — + // 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28). + const extras: Array = []; + 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" : "", + ]; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 9af19b04..dcce6cb4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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) => void; - /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ - pipeChainages: () => number[]; - /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ - movePipe: (fromChainage: number, toChainage: number) => void; - /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). - * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */ - setPipeChainages: (chainages: ReadonlyArray) => void; - /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때. - * attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */ - addPipe: (chainageM: number, attributes?: FacilityAttributes) => void; - /** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */ - updatePipeFacility: ( - fromChainageM: number, - toChainageM: number, - attributes: FacilityAttributes, - ) => void; - removePipe: (chainageM: number) => void; - /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ - savePipes: () => Promise; - /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ - 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; - }>, - ) => 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( diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts new file mode 100644 index 00000000..f50a2375 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -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) => void; + /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ + pipeChainages: () => number[]; + /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ + movePipe: (fromChainage: number, toChainage: number) => void; + /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). + * 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */ + setPipeChainages: (chainages: ReadonlyArray) => void; + /** 종단 테이블 우클릭·통합 목록에서 계곡 통과 시설을 넣을 때. + * attributes에 시설 종류·구간·부속 옵션이 담긴다(생략 = 맨 배관). */ + addPipe: (chainageM: number, attributes?: FacilityAttributes) => void; + /** 사이드 폼 [수정] — 기준점 이동·구간·부속 옵션을 정본에 반영하고 재계산한다. */ + updatePipeFacility: ( + fromChainageM: number, + toChainageM: number, + attributes: FacilityAttributes, + ) => void; + removePipe: (chainageM: number) => void; + /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ + savePipes: () => Promise; + /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ + 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; + }>, + ) => 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[]; +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index b99820a1..6ffbde49 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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 { } 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 { } } - async function solve(): Promise { - 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 { - 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 { - 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, + }; /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts new file mode 100644 index 00000000..d54c74d5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -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; + panel: () => ReturnType; + profilePanel: () => ReturnType; + bridge: () => ReturnType; + /** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 문자열. */ + uphillOverrides: Map; + persistUphillOverrides: () => void; + loadLatest: (forceFresh?: boolean) => Promise; + renderLatest: (next: RouteLatestResponse) => void; + restoreSections: (routeId: number) => Promise; +} + +/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */ +export async function solveRouteAction(ctx: PageActionContext): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 9024499f..51cd5ea1 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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 { 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(); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts new file mode 100644 index 00000000..6ff036bb --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -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); + }, + }; +} diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 3ce92495..ab2ff622 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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; - -// 지반유형·토량환산계수·운반장비 한계거리는 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 | 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; - /** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */ - extra_wall_counts?: StoredExtraWallCounts; - /** 다단 기슭막이 **단별** 구간값 — 키는 벽 키("extra0"…/"bextra0"…). 기준벽 연장에 - * 종속되지 않고 단마다 따로 잡는다(2026-08-29 사용자). 없으면 기본 10m(5/5). */ - extra_spans?: Record; - /** 연동 해제(측점별 — 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; - ford_adjust?: StoredFordAdjust; - box_adjust?: StoredBoxAdjust; - extra_wall_counts?: StoredExtraWallCounts; - extra_spans?: Record; - /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 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(path: string, init: RequestInit): Promise { diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts new file mode 100644 index 00000000..23d7e724 --- /dev/null +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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; + +// 지반유형·토량환산계수·운반장비 한계거리는 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 | 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; + /** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */ + extra_wall_counts?: StoredExtraWallCounts; + /** 다단 기슭막이 **단별** 구간값 — 키는 벽 키("extra0"…/"bextra0"…). 기준벽 연장에 + * 종속되지 않고 단마다 따로 잡는다(2026-08-29 사용자). 없으면 기본 10m(5/5). */ + extra_spans?: Record; + /** 연동 해제(측점별 — 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; + ford_adjust?: StoredFordAdjust; + box_adjust?: StoredBoxAdjust; + extra_wall_counts?: StoredExtraWallCounts; + extra_spans?: Record; + /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ + revet_link_detached?: boolean; + revet_follow_grade?: boolean; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 0e56e9ed..7568e7b3 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -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 { 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 { 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, - ): 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 = (target: T, field: keyof T, value: unknown): void => { - if (value !== undefined) (target as Record)[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 { } } - /* ── 암 경계선 오프셋(측점별) 세션 저장소 ───────────────────────────── - * 서버 재계산 없이 프론트 세션(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(); - 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; - 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 { 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 { } } - /** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ - function collectSectionEdits(): { - crossPatches: CrossSectionPatch[]; - massHaul: Record | 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 { 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 { - 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 { - 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 { // (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 { } currentRouteId = context.route_id; - loadRockOffsets(); + rockStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. void structuresPanel.load(); diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index 0aaebb7e..a014cc93 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -3,8 +3,60 @@ * 연동 기슭막이 옵션(연동 해제·종단경사 반영)의 **세션 보관** — 측점 제어기 * (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25). * 두 플래그는 한 세션 항목에 같이 담아 확정 전 리로드에도 살아남는다. + * + * 측점별 조작값을 담는 **한 겹 맵**도 여기서 낸다(`createSessionMap`, 2026-09-02) — + * 반폭·기슭막이 4축·유입 형식·집수정·추가 벽 수·단별 구간값이 같은 모양(읽기 → 항목 + * 검증 → 담기 / 쓰기 → 통째 직렬화)이라 제어기마다 짝을 두던 것을 하나로 모았다. * ========================================================================== */ +/** 세션에 담기는 측점별 조작값 한 겹 맵. */ +export interface SessionMap { + values: Map; + /** 세션값 읽기 — 맵을 비우고 다시 채운다. */ + load: () => void; + /** 맵을 통째로 세션에 쓴다. */ + persist: () => void; +} + +/** + * 세션 보관 맵을 만든다. `accept` 가 항목마다 값을 검증·정규화해 돌려주고, + * `undefined` 를 내면 그 항목은 버린다(손상값·옛 형식 정리 자리). + */ +export function createSessionMap( + sessionKey: () => string | null, + accept: (value: unknown) => V | undefined, +): SessionMap { + const values = new Map(); + 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; + 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; followGrade: Map; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts new file mode 100644 index 00000000..765edcfa --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -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; + 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(); + 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; + 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; + /** 측점별 편집분의 출처 묶음 — 세션·제어기에 흩어진 값을 페이지가 모아 준다. */ + patchSources: () => CrossPatchSources; +} + +/** 확정과 임시 저장이 함께 보내는 편집분(암 경계선 오프셋 + 유토곡선 + balloon 위치). */ +export function collectSectionEdits(ctx: SectionPersistContext): { + crossPatches: CrossSectionPatch[]; + massHaul: Record | 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 { + // 조정창 구간값은 세션에만 있다 — 정본 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 { + 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 { + 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(); + } +} diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts new file mode 100644 index 00000000..7d555281 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -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; + +/** 폼 → 캐시 반영에 필요한 페이지 창구만 모은 것. */ +export interface PipeOptionsContext { + detail: () => SectionDetailResponse | null; + ford: StationControls["ford"]; + box: StationControls["box"]; + revetOffset: StationControls["revetOffset"]; + /** 기하가 잘라 낸 실제 값을 폼에 되돌린다. */ + overrideOptions: (chainageM: number, values: Record) => void; + refreshCard: (chainageM: number) => void; +} + +/** + * 폼에서 바꾼 관 옵션을 **횡단 캐시**(`section.culvert` 스펙)에 얹고, 그 구조물이 + * 덮는 측점 카드를 다시 그린다 — 조정창 조작과 같은 흐름이라 도면이 바로 따라온다 + * (2026-08-29 사용자 보고: 값만 바뀌고 횡단도가 그대로였다). + * 여기서는 화면 캐시만 고친다 — 영구저장은 [저장]·[확정] 몫이다. + */ +export function applyPipeOptionsToCache( + ctx: PipeOptionsContext, + chainageM: number, + patch: Record, +): 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 = (target: T, field: keyof T, value: unknown): void => { + if (value !== undefined) (target as Record)[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); + } + } +} diff --git a/B06_Section/B06_Section_UI_Page_Span_Control.ts b/B06_Section/B06_Section_UI_Page_Span_Control.ts new file mode 100644 index 00000000..2a9ffb30 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Span_Control.ts @@ -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; + persistExtraSpans: () => void; + patchCachedDesign: (chainageM: number, patch: Partial) => void; + /** 정본 반영 큐(관 옵션). */ + culvertOptions: { queue: (chainageM: number, values: Record) => void }; +} + +/** 구간값 제어기와 단별 구간값 조회를 만든다. */ +export function createSpanControl(deps: SpanControlDeps): { + control: StructureSpanControl; + /** 이 측점의 단별 구간값 전부(세션 우선) — 상위 제어기가 payload에 실을 때 쓴다. */ + tierSpansOf: (owner: CrossSection) => Record; + 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 => { + const prefix = `${owner.chainage_m.toFixed(2)}:`; + const result: Record = { ...(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 }; +} diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index ac8c0db3..095e6be5 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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(); + const widthSession = createSessionMap( + () => 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; - 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(); + const revetSession = createSessionMap( + () => 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; + 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>; - 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(); - const basinAdjustments = new Map(); - 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; - 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( + () => deps.sessionKey("basinadjust"), + (value) => + value && typeof value === "object" + ? { ...DEFAULT_BASIN_ADJUST, ...(value as Partial) } + : undefined, + ); + const structSession = createSessionMap( + () => 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(); - const extraSessionKey = (): string | null => deps.sessionKey("extrawall"); + const extraSession = createSessionMap( + () => 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; - 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(); // 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다. @@ -551,130 +451,30 @@ export function createStationControls(deps: StationControlDeps): StationControls * 기준벽 연장에 종속시키지 않는다 — 계곡부·능선부에서 아래 단일수록 연장이 달라져 * 자동 규칙으로 못 잡는다. 제어는 세션이고 [저장]·[확정] 때 정본으로 간다 * (4축 조작값과 같은 경로). 키는 `누가거리:벽키`(예 `234.10:extra0`). */ - const extraSpans = new Map(); - const extraSpanSessionKey = (): string | null => deps.sessionKey("extraspan"); - const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`; + const extraSpanSession = createSessionMap( + () => 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; - 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 => { - const prefix = `${owner.chainage_m.toFixed(2)}:`; - const result: Record = { ...(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 => { diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index defbab99..accd314c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -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( - path: string, - init: RequestInit = {}, -): Promise { +async function requestJson(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -116,17 +108,14 @@ async function requestJson( 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 { +export function fetchDesignDrawingList(projectId: string): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -134,9 +123,7 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - 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 { +export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { 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 { +export function fetchFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate( - projectId: string, - drawing: CadDrawing, -): Promise { +export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index 090c3991..e79f7336 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -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))}, ) ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py index 3c54ffb6..e9dbf92b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py @@ -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), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py index 54d74e9f..b1c76fa1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py @@ -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, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index 6fce8c87..e9b37c2c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -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))}, ) ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 9dd44217..43a1e52d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -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, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index ff8711a1..eb8dec65 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -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 {}) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 6104dd44..49cc9d7e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index d536da10..5d0396d8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -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) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 7e1bdd3d..f7a1fafc 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -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; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 32fd5f38..807467e4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -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 = { 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 { 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 { // 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관). 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 { ) ?? undefined; const highlightActive = (drawingId: string) => { - drawingListEl - ?.querySelectorAll(".b07-drawing-button") - .forEach((item) => { - item.dataset.active = String(item.dataset.drawingId === drawingId); - }); + drawingListEl?.querySelectorAll(".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 { const drawingCache = new Map>(); /** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */ - const requestDrawing = ( - drawing: DesignDrawingItem, - ): Promise => { + const requestDrawing = (drawing: DesignDrawingItem): Promise => { 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 { } 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 { const requestCadDrawing = (): Promise => 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 { } } 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 { 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 { cadHost.prepend(frameEditor.banner); window.addEventListener("message", (event: MessageEvent) => { - 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 { (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 { } 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 { 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 { } }); - 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 { onStepClick: (stepIndex) => { if (!projectId) return; if (stepIndex > 5 && !allDrawingsConfirmed) { - showToast( - "모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", - "warning", - ); + showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); return; } goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 8ba7b7f2..f2ca6ee6 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/blocks/block-library.ts b/B07_DesignDetail/openwebcad/src/blocks/block-library.ts index f820f638..6010be3a 100644 --- a/B07_DesignDetail/openwebcad/src/blocks/block-library.ts +++ b/B07_DesignDetail/openwebcad/src/blocks/block-library.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts b/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts index 2bd50c85..e5adc8ea 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.draw.ts @@ -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, diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 3b0a4a86..3c905bb6 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -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()을 부른다. */ diff --git a/B07_DesignDetail/openwebcad/src/components/Button.tsx b/B07_DesignDetail/openwebcad/src/components/Button.tsx index b1f3f087..f95ddc2c 100644 --- a/B07_DesignDetail/openwebcad/src/components/Button.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Button.tsx @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx b/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx index 94b2883d..995abf0c 100644 --- a/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx +++ b/B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx b/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx index 0de5382a..2ac2af46 100644 --- a/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx @@ -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'; diff --git a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx index 0eb76091..b640e300 100644 --- a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx +++ b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx b/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx index 3a2e5e8b..a4410ef4 100644 --- a/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx +++ b/B07_DesignDetail/openwebcad/src/components/PropertiesEditor.tsx @@ -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 = ({ compact = false })
시작점
-
- {points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'} -
+
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
그룹
diff --git a/B07_DesignDetail/openwebcad/src/components/ui-state.ts b/B07_DesignDetail/openwebcad/src/components/ui-state.ts index 32462b24..b6a19552 100644 --- a/B07_DesignDetail/openwebcad/src/components/ui-state.ts +++ b/B07_DesignDetail/openwebcad/src/components/ui-state.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts new file mode 100644 index 00000000..bad08fe9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts @@ -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]); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 753a1803..4facbdbd 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -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); } diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts index 3103a7d6..61f38903 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/svg.drawController.ts @@ -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'; diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts index 33e6b5df..c094d88b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts @@ -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', () => { /** diff --git a/B07_DesignDetail/openwebcad/src/entities/Entity.ts b/B07_DesignDetail/openwebcad/src/entities/Entity.ts index 23dc7403..135f4be9 100644 --- a/B07_DesignDetail/openwebcad/src/entities/Entity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/Entity.ts @@ -86,6 +86,8 @@ export interface JsonEntity { lineWidth: number; lineDash?: number[]; layerId: string; + /** GROUP 묶음 식별자 — 저장·복원에서 그대로 실어 나른다 */ + groupId?: string; shapeData: TShapeJsonData | null; children?: JsonEntity[]; } diff --git a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts index d15fcc23..a9b58a27 100644 --- a/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/HatchEntity.ts @@ -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]); } diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.test.ts new file mode 100644 index 00000000..63310b39 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.test.ts @@ -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); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index 058439f4..3476aa8b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts index 2cdf617a..8b31c097 100644 --- a/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/LineEntity.test.ts @@ -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', () => { diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts index 037ea0ce..29077ac3 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.test.ts @@ -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', () => ({ diff --git a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts index ea3a408e..08504b3b 100644 --- a/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts b/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts index 5e6a74d5..b1406801 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/box-to-polygon.ts @@ -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)), + ]); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts b/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts index 3667eec5..8dc9d7c9 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/cad-clipboard.ts @@ -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; }); diff --git a/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts b/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts index 4eca92c4..2f2c405f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/calculate-angle-guides-and-snap-points.ts @@ -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); + } } diff --git a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts index e5188651..fa1cc208 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.test.ts @@ -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 }); + }); }); diff --git a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts index 3e8d8f56..561f7692 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/contain-rect.ts @@ -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, + }; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts index 846d08b5..24f02b08 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.test.ts @@ -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', () => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts index cd8b83cc..2a6ec29f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/convert-svg-path-to-line-segments.ts @@ -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 }; diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index 4fa3f95e..c54fbaf6 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -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 }; + }, }; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts index efd39457..b29968bc 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.mocks.ts @@ -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, }, }, diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts index 6df72399..da946ca4 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-closest-entity.test.ts @@ -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 () => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts index 9b13a698..888a948d 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-arc.ts @@ -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], + ]; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts index b8dda812..faa847ae 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-circle.ts @@ -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], + ]; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts index c6b38a91..92f301ed 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/find-neighboring-points-on-line.ts @@ -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, + ]; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts index 6cd36aa1..625155be 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/entity-loop.ts @@ -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); diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts index 788d4891..2f3ee170 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/sample-entity.ts @@ -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(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts b/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts index 94bc2ac9..1eec3633 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/geometry/shape-points.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts index 0ab99254..e9532b43 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-guide-lines.ts @@ -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; + }); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts index 5df7cd6e..2c311ef3 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.test.ts @@ -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', () => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts index dd1ed291..cf615d27 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-angle-with-x-axis.ts @@ -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; diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts b/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts index b5731a1f..d6b10479 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-bounding-box-of-multiple-entities.ts @@ -1,4 +1,4 @@ -import type {Entity} from "../entities/Entity.ts"; +import type { Entity } from '../entities/Entity.ts'; export interface BoundingBox { minX: number; diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts b/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts index 826442a3..5f77e585 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-closest-snap-point.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts b/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts index e6ad8a5a..d67e7390 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-draw-guides.ts @@ -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 diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts b/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts index 22eb2703..9e9c13ac 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-intersection-points.ts @@ -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; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts index 3856a546..76565a4e 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -1,4 +1,4 @@ -import {type Point, Vector} from '@flatten-js/core'; +import { type Point, Vector } from '@flatten-js/core'; import { type AbsolutePointInputEvent, ActorEvent, diff --git a/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts b/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts index fc56a4da..3bd2d602 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/helpers.types.ts @@ -1,6 +1,6 @@ import type { Point } from '@flatten-js/core'; export interface PointWithAngle { - point: Point; - angle: number; + point: Point; + angle: number; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts index d33f56c1..1cc97229 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts @@ -1,8 +1,8 @@ -import {compact} from 'es-toolkit'; -import {saveAs} from 'file-saver'; -import type {Layer} from '../../App.types.ts'; -import type {Entity, JsonEntity} from '../../entities/Entity'; -import {getEntities, getLayers} from '../../state'; +import { compact } from 'es-toolkit'; +import { saveAs } from 'file-saver'; +import type { Layer } from '../../App.types.ts'; +import type { Entity, JsonEntity } from '../../entities/Entity'; +import { getEntities, getLayers } from '../../state'; export async function exportEntitiesToJsonFile() { const json = await exportEntitiesAndLayersToJsonString(); @@ -14,7 +14,11 @@ export async function exportEntitiesToJsonFile() { export async function exportEntitiesAndLayersToJsonString() { const entities = getEntities(); - const jsonEntities = entities.map((entity) => entity.toJson()); + // groupId는 엔티티 13종의 toJson을 다 고치는 대신 여기 한 곳에서 붙인다. + const jsonEntities = entities.map(async (entity) => { + const json = await entity.toJson(); + return json && entity.groupId ? { ...json, groupId: entity.groupId } : json; + }); const jsonDrawingFile: JsonDrawingFileSerialized = { entities: compact(await Promise.all(jsonEntities)), // TODO use a mapLimit to avoid overloading the event loop layers: getLayers(), diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts index 73243c8c..1b2ce9e1 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts @@ -1,5 +1,5 @@ -import {LOCAL_STORAGE_KEY} from '../../App.types.ts'; -import {exportEntitiesAndLayersToJsonString} from './export-entities-to-json.ts'; +import { LOCAL_STORAGE_KEY } from '../../App.types.ts'; +import { exportEntitiesAndLayersToJsonString } from './export-entities-to-json.ts'; export async function exportEntitiesToLocalStorage() { const json = await exportEntitiesAndLayersToJsonString(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts index aa69bdb9..d54b3502 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts @@ -12,57 +12,52 @@ import { getEntities } from '../../state'; * @param margin */ export function convertSvgToPngBlob( - svgLines: string[], - width: number, - height: number, - margin: number, + svgLines: string[], + width: number, + height: number, + margin: number ): Promise { - return new Promise((resolve, reject) => { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Could not get canvas context'); - } + if (!ctx) { + throw new Error('Could not get canvas context'); + } - const img = new Image(); - const svg = new Blob(svgLines, { type: 'image/svg+xml' }); - const url = URL.createObjectURL(svg); + const img = new Image(); + const svg = new Blob(svgLines, { type: 'image/svg+xml' }); + const url = URL.createObjectURL(svg); - img.onload = () => { - canvas.width = width + margin * 2; - canvas.height = height + margin * 2; + img.onload = () => { + canvas.width = width + margin * 2; + canvas.height = height + margin * 2; - ctx.fillStyle = 'white'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, margin, margin); + ctx.drawImage(img, margin, margin); - URL.revokeObjectURL(url); + URL.revokeObjectURL(url); - canvas.toBlob(blob => { - if (blob) { - resolve(blob); - } else { - reject(new Error('Could not convert canvas to blob')); - } - }, 'image/png'); - }; + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Could not convert canvas to blob')); + } + }, 'image/png'); + }; - img.src = url; - }); + img.src = url; + }); } export async function exportEntitiesToPngFile() { - const entities = getEntities(); + const entities = getEntities(); - const svg = convertEntitiesToSvgString(entities); - const pngDataBlob: Blob = await convertSvgToPngBlob( - svg.svgLines, - svg.width, - svg.height, - 20, - ); + const svg = convertEntitiesToSvgString(entities); + const pngDataBlob: Blob = await convertSvgToPngBlob(svg.svgLines, svg.width, svg.height, 20); - saveAs(pngDataBlob, 'open-web-cad--drawing.png'); + saveAs(pngDataBlob, 'open-web-cad--drawing.png'); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts index 96898837..2beeef0d 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts @@ -1,9 +1,9 @@ -import {saveAs} from 'file-saver'; -import {SVG_MARGIN} from '../../App.consts'; -import {SvgDrawController} from '../../drawControllers/svg.drawController.ts'; -import type {Entity} from '../../entities/Entity'; -import {getEntities} from '../../state'; -import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts'; +import { saveAs } from 'file-saver'; +import { SVG_MARGIN } from '../../App.consts'; +import { SvgDrawController } from '../../drawControllers/svg.drawController.ts'; +import type { Entity } from '../../entities/Entity'; +import { getEntities } from '../../state'; +import { getBoundingBoxOfMultipleEntities } from '../get-bounding-box-of-multiple-entities.ts'; export function convertEntitiesToSvgString(entities: Entity[]): { svgLines: string[]; diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/group-id-roundtrip.test.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/group-id-roundtrip.test.ts new file mode 100644 index 00000000..d0ee722c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/group-id-roundtrip.test.ts @@ -0,0 +1,30 @@ +import { Point } from '@flatten-js/core'; +import { describe, expect, it } from 'vitest'; +import { LineEntity } from '../../entities/LineEntity.ts'; +import { setEntities, setLayers } from '../../state.ts'; +import { exportEntitiesAndLayersToJsonString } from './export-entities-to-json.ts'; +import { getEntitiesAndLayersFromJsonString } from './import-entities-from-json.ts'; + +describe('groupId 저장·복원', () => { + it('GROUP 묶음이 JSON 왕복 뒤에도 한 덩어리로 남는다', async () => { + const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' }; + const groupId = 'group-1'; + const first = new LineEntity(layer.id, new Point(0, 0), new Point(1, 0)); + const second = new LineEntity(layer.id, new Point(1, 0), new Point(1, 1)); + const loner = new LineEntity(layer.id, new Point(2, 2), new Point(3, 3)); + first.groupId = groupId; + second.groupId = groupId; + + setLayers([layer]); + setEntities([first, second, loner]); + + const restored = await getEntitiesAndLayersFromJsonString( + await exportEntitiesAndLayersToJsonString() + ); + const byId = new Map(restored.entities.map((entity) => [entity.id, entity])); + + expect(byId.get(first.id)?.groupId).toBe(groupId); + expect(byId.get(second.id)?.groupId).toBe(groupId); + expect(byId.get(loner.id)?.groupId).toBeUndefined(); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts index c121cf78..6cfbf049 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts @@ -77,6 +77,12 @@ export async function getEntitiesAndLayersFromJsonObject( ); const entities = compact(await Promise.all(entityPromises)); + // 순번이 아니라 id로 되돌린다 — compact가 null을 걷어 index가 어긋날 수 있다. + const groupIdById = new Map(data.entities.map((entity) => [entity.id, entity.groupId])); + for (const entity of entities) { + const groupId = groupIdById.get(entity.id); + if (groupId) entity.groupId = groupId; + } let layers = data.layers; if (data.layers.length === 0) { layers = [getNewLayer()]; diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts index 91184e79..fc588a56 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts @@ -1,8 +1,8 @@ -import {LOCAL_STORAGE_KEY} from '../../App.types.ts'; -import {setActiveLayerId, setEntities, setLayers} from '../../state.ts'; -import {getNewLayer} from '../get-new-layer.ts'; -import {getEntitiesAndLayersFromJsonString} from './import-entities-from-json.ts'; -import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts"; +import { LOCAL_STORAGE_KEY } from '../../App.types.ts'; +import { setActiveLayerId, setEntities, setLayers } from '../../state.ts'; +import { getNewLayer } from '../get-new-layer.ts'; +import { getEntitiesAndLayersFromJsonString } from './import-entities-from-json.ts'; +import type { JsonDrawingFileDeserialized } from './export-entities-to-json.ts'; export async function importEntitiesAndLayersFromLocalStorage(): Promise { const file = await getEntitiesAndLayersFromLocalStorage(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts index 1e0ee91b..76c51e4f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts @@ -1,15 +1,15 @@ -import {toast} from 'react-toastify'; -import {CircleEntity} from '../../entities/CircleEntity'; -import type {Entity} from '../../entities/Entity'; -import {LineEntity} from '../../entities/LineEntity'; -import {RectangleEntity} from '../../entities/RectangleEntity'; -import {getActiveLayerId, getEntities, setEntities} from '../../state'; +import { toast } from 'react-toastify'; +import { CircleEntity } from '../../entities/CircleEntity'; +import type { Entity } from '../../entities/Entity'; +import { LineEntity } from '../../entities/LineEntity'; +import { RectangleEntity } from '../../entities/RectangleEntity'; +import { getActiveLayerId, getEntities, setEntities } from '../../state'; -import {Point} from '@flatten-js/core'; -import {type Node, parse, type RootNode} from 'svg-parser'; -import {svgPathToSegments} from '../convert-svg-path-to-line-segments.ts'; -import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts'; -import {middle} from '../middle.ts'; +import { Point } from '@flatten-js/core'; +import { type Node, parse, type RootNode } from 'svg-parser'; +import { svgPathToSegments } from '../convert-svg-path-to-line-segments.ts'; +import { getBoundingBoxOfMultipleEntities } from '../get-bounding-box-of-multiple-entities.ts'; +import { middle } from '../middle.ts'; function svgChildrenToEntities(root: RootNode): Entity[] { if (!root.children || !root.children?.[0]) { diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts index 6769ce86..3d92c50c 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts @@ -1,12 +1,12 @@ export interface SvgParseResult { - type: string - children: Children[] + type: string; + children: Children[]; } export interface Children { - type: string - tagName: string - properties: Record - children: Children[] - metadata?: string + type: string; + tagName: string; + properties: Record; + children: Children[]; + metadata?: string; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts index bbc74e7c..a1e71e11 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts @@ -3,16 +3,14 @@ * Load the image data * Convert it to a base64 string */ -export function importImageFromFile( - file: File | null | undefined, -): Promise { - return new Promise(resolve => { - if (!file) return; +export function importImageFromFile(file: File | null | undefined): Promise { + return new Promise((resolve) => { + if (!file) return; - const img = new Image(); - img.onload = () => { - resolve(img); - }; - img.src = URL.createObjectURL(file); - }); + const img = new Image(); + img.onload = () => { + resolve(img); + }; + img.src = URL.createObjectURL(file); + }); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts index 9d46988d..83f29ab3 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.test.ts @@ -1,7 +1,7 @@ -import {Point} from '@flatten-js/core'; -import {describe, expect, it} from 'vitest'; -import type {StartAndEndpointEntity} from '../App.types.ts'; -import {isClosedPolygon} from './is-closed-polygon.ts'; // Mock implementation for StartAndEndpointEntity +import { Point } from '@flatten-js/core'; +import { describe, expect, it } from 'vitest'; +import type { StartAndEndpointEntity } from '../App.types.ts'; +import { isClosedPolygon } from './is-closed-polygon.ts'; // Mock implementation for StartAndEndpointEntity // Mock implementation for StartAndEndpointEntity class MockEntity implements StartAndEndpointEntity { diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts index 37d9aed4..93a58b19 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-closed-polygon.ts @@ -1,6 +1,6 @@ -import type {Point} from "@flatten-js/core"; -import type {StartAndEndpointEntity} from "../App.types.ts"; -import {isPointEqual} from "./is-point-equal.ts"; +import type { Point } from '@flatten-js/core'; +import type { StartAndEndpointEntity } from '../App.types.ts'; +import { isPointEqual } from './is-point-equal.ts'; /** * Check if entities form a closed loop polygon diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts b/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts index 93e6675d..6ed3ce24 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-length-equal.ts @@ -1,5 +1,5 @@ import { EPSILON } from '../App.consts'; export function isLengthEqual(length1: number, length2: number): boolean { - return Math.abs(length1 - length2) < EPSILON; + return Math.abs(length1 - length2) < EPSILON; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts b/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts index 43eb67d4..644ef38f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/is-point-equal.ts @@ -2,8 +2,5 @@ import type { Point } from '@flatten-js/core'; import { EPSILON } from '../App.consts'; export function isPointEqual(point1: Point, point2: Point): boolean { - return ( - Math.abs(point1.x - point2.x) < EPSILON && - Math.abs(point1.y - point2.y) < EPSILON - ); + return Math.abs(point1.x - point2.x) < EPSILON && Math.abs(point1.y - point2.y) < EPSILON; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts b/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts index 9d25cd4d..1f69e423 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/keyboard-handler.ts @@ -1,4 +1,4 @@ -import type {KeyboardEvent} from "react"; +import type { KeyboardEvent } from 'react'; export function keyboardHandler(clickHandler: () => void) { return (evt: KeyboardEvent) => { diff --git a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts index 83dddaec..22d4bdc7 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.test.ts @@ -2,60 +2,60 @@ import { describe, expect, it } from 'vitest'; import { mapNumberRange } from './map-number-range'; describe('mapNumberRange', () => { - it('should map a value from the source range to the target range (normal range)', () => { - expect(mapNumberRange(5, 0, 10, 0, 100)).toBe(50); - expect(mapNumberRange(0, 0, 10, 0, 100)).toBe(0); - expect(mapNumberRange(10, 0, 10, 0, 100)).toBe(100); - }); + it('should map a value from the source range to the target range (normal range)', () => { + expect(mapNumberRange(5, 0, 10, 0, 100)).toBe(50); + expect(mapNumberRange(0, 0, 10, 0, 100)).toBe(0); + expect(mapNumberRange(10, 0, 10, 0, 100)).toBe(100); + }); - it('should map values outside the source range', () => { - expect(mapNumberRange(-5, 0, 10, 0, 100)).toBe(-50); // Extrapolate below source range - expect(mapNumberRange(15, 0, 10, 0, 100)).toBe(150); // Extrapolate above source range - }); + it('should map values outside the source range', () => { + expect(mapNumberRange(-5, 0, 10, 0, 100)).toBe(-50); // Extrapolate below source range + expect(mapNumberRange(15, 0, 10, 0, 100)).toBe(150); // Extrapolate above source range + }); - it('should handle inverted source ranges', () => { - // Source range is 10 to 0, mapping 5 should be halfway - // Target range is 100 to 0, so halfway is 50 - expect(mapNumberRange(5, 10, 0, 100, 0)).toBe(50); + it('should handle inverted source ranges', () => { + // Source range is 10 to 0, mapping 5 should be halfway + // Target range is 100 to 0, so halfway is 50 + expect(mapNumberRange(5, 10, 0, 100, 0)).toBe(50); - // Outside inverted range - expect(mapNumberRange(15, 10, 0, 100, 0)).toBe(150); - expect(mapNumberRange(-5, 10, 0, 100, 0)).toBe(-50); - }); + // Outside inverted range + expect(mapNumberRange(15, 10, 0, 100, 0)).toBe(150); + expect(mapNumberRange(-5, 10, 0, 100, 0)).toBe(-50); + }); - it('should handle inverted target ranges', () => { - // Normal source range, but inverted target - expect(mapNumberRange(5, 0, 10, 100, 0)).toBe(50); - expect(mapNumberRange(0, 0, 10, 100, 0)).toBe(100); - expect(mapNumberRange(10, 0, 10, 100, 0)).toBe(0); - }); + it('should handle inverted target ranges', () => { + // Normal source range, but inverted target + expect(mapNumberRange(5, 0, 10, 100, 0)).toBe(50); + expect(mapNumberRange(0, 0, 10, 100, 0)).toBe(100); + expect(mapNumberRange(10, 0, 10, 100, 0)).toBe(0); + }); - it('should handle zero-length source range', () => { - // If the source range is a single point - expect(mapNumberRange(5, 10, 10, 0, 100)).toBe(0); // Returns start of target range - expect(mapNumberRange(10, 10, 10, 20, 40)).toBe(20); // Returns start of target range - }); + it('should handle zero-length source range', () => { + // If the source range is a single point + expect(mapNumberRange(5, 10, 10, 0, 100)).toBe(0); // Returns start of target range + expect(mapNumberRange(10, 10, 10, 20, 40)).toBe(20); // Returns start of target range + }); - it('should handle negative numbers and other ranges', () => { - expect(mapNumberRange(-10, -20, 0, 0, 100)).toBe(50); - // Here: num = -10, source = [-20,0], target = [0,100] - // Mapping: (-10 - (-20)) / (0 - (-20)) = 10/20 = 0.5 -> 0 + 0.5*100 = 50 - }); + it('should handle negative numbers and other ranges', () => { + expect(mapNumberRange(-10, -20, 0, 0, 100)).toBe(50); + // Here: num = -10, source = [-20,0], target = [0,100] + // Mapping: (-10 - (-20)) / (0 - (-20)) = 10/20 = 0.5 -> 0 + 0.5*100 = 50 + }); - it('should handle floating point values', () => { - expect(mapNumberRange(2.5, 0, 10, 0, 100)).toBe(25); // Fractional input - expect(mapNumberRange(1.5, 0, 3, 0, 1)).toBeCloseTo(0.5, 6); // Precision check - }); + it('should handle floating point values', () => { + expect(mapNumberRange(2.5, 0, 10, 0, 100)).toBe(25); // Fractional input + expect(mapNumberRange(1.5, 0, 3, 0, 1)).toBeCloseTo(0.5, 6); // Precision check + }); - it('should handle large ranges', () => { - expect(mapNumberRange(500, 0, 1000, 0, 1_000_000)).toBe(500_000); - }); + it('should handle large ranges', () => { + expect(mapNumberRange(500, 0, 1000, 0, 1_000_000)).toBe(500_000); + }); - it('should handle screen coordinates to world correctly', () => { - expect(mapNumberRange(100, 0, 1000, 1000, 0)).toBe(900); - }); + it('should handle screen coordinates to world correctly', () => { + expect(mapNumberRange(100, 0, 1000, 1000, 0)).toBe(900); + }); - it('should handle world coordinates to screen correctly', () => { - expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100); - }); + it('should handle world coordinates to screen correctly', () => { + expect(mapNumberRange(900, 1000, 0, 0, 1000)).toBe(100); + }); }); diff --git a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts index 47c1d601..b4df9aec 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/map-number-range.ts @@ -3,20 +3,20 @@ * This is moslty used to convert screen space coordinates to world space coordinates and vice versa */ export function mapNumberRange( - num: number, - startSourceRange: number, - endSourceRange: number, - startTargetRange: number, - endTargetRange: number, + num: number, + startSourceRange: number, + endSourceRange: number, + startTargetRange: number, + endTargetRange: number ): number { - // Handle the case where source range has zero length - if (startSourceRange === endSourceRange) { - return startTargetRange; - } + // Handle the case where source range has zero length + if (startSourceRange === endSourceRange) { + return startTargetRange; + } - return ( - startTargetRange + - ((num - startSourceRange) * (endTargetRange - startTargetRange)) / - (endSourceRange - startSourceRange) - ); + return ( + startTargetRange + + ((num - startSourceRange) * (endTargetRange - startTargetRange)) / + (endSourceRange - startSourceRange) + ); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts index 5cf8e11e..75e88f55 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-angle-over-axis.ts @@ -1,4 +1,4 @@ -import type {LineEntity} from "../entities/LineEntity.ts"; +import type { LineEntity } from '../entities/LineEntity.ts'; export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) { const mirrorAngle = mirrorAxis.getAngle(); diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts index 8bc9c478..8842ef35 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.test.ts @@ -1,10 +1,10 @@ -import {describe, expect, it} from "vitest"; -import {Point} from "@flatten-js/core"; -import {mirrorPointOverAxis} from './mirror-point-over-axis'; -import {LineEntity} from "../entities/LineEntity.ts"; -import {getActiveLayerId} from "../state.ts"; +import { describe, expect, it } from 'vitest'; +import { Point } from '@flatten-js/core'; +import { mirrorPointOverAxis } from './mirror-point-over-axis'; +import { LineEntity } from '../entities/LineEntity.ts'; +import { getActiveLayerId } from '../state.ts'; -describe("mirrorPointOverAxis", () => { +describe('mirrorPointOverAxis', () => { it('should mirror if the axis is horizontal', () => { const point = new Point(100, 100); const axis = new LineEntity(getActiveLayerId(), new Point(0, 50), new Point(50, 50)); @@ -13,7 +13,7 @@ describe("mirrorPointOverAxis", () => { expect(mirroredPoint.y).toBe(0); }); - it("mirrors a point over a vertical axis", () => { + it('mirrors a point over a vertical axis', () => { const point = new Point(3, 4); const axis = new LineEntity(getActiveLayerId(), new Point(0, -1), new Point(0, 1)); // Vertical line at x=0 const mirrored = mirrorPointOverAxis(point, axis); @@ -21,7 +21,7 @@ describe("mirrorPointOverAxis", () => { expect(mirrored.y).toBeCloseTo(4); }); - it("mirrors a point over the diagonal line y = x", () => { + it('mirrors a point over the diagonal line y = x', () => { const point = new Point(3, 4); const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(1, 1)); // Line y=x const mirrored = mirrorPointOverAxis(point, axis); @@ -30,7 +30,7 @@ describe("mirrorPointOverAxis", () => { expect(mirrored.y).toBeCloseTo(3); }); - it("returns the same point if the point lies on the mirror axis", () => { + it('returns the same point if the point lies on the mirror axis', () => { const point = new Point(1, 1); const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(2, 2)); // Point (1,1) lies on this line const mirrored = mirrorPointOverAxis(point, axis); @@ -38,7 +38,7 @@ describe("mirrorPointOverAxis", () => { expect(mirrored.y).toBeCloseTo(1); }); - it("returns the original point when mirrored twice", () => { + it('returns the original point when mirrored twice', () => { const point = new Point(5, 7); const axis = new LineEntity(getActiveLayerId(), new Point(2, 3), new Point(8, 11)); // Arbitrary axis const mirrored = mirrorPointOverAxis(point, axis); diff --git a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts index b7ea62db..f9e729fc 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/mirror-point-over-axis.ts @@ -1,5 +1,5 @@ import { Point, type Segment } from '@flatten-js/core'; -import type {LineEntity} from "../entities/LineEntity.ts"; +import type { LineEntity } from '../entities/LineEntity.ts'; export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) { const mirrorAxisSegment = mirrorAxis.getShape() as Segment; diff --git a/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts b/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts index 5b042adf..c5bae1e7 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/polygon-to-segments.ts @@ -1,4 +1,4 @@ -import type {Polygon, Segment} from '@flatten-js/core'; +import type { Polygon, Segment } from '@flatten-js/core'; export function polygonToSegments(polygon: Polygon): Segment[] { const segments: Segment[] = []; diff --git a/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts b/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts index f4597109..10f7dc21 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/rotate-point.ts @@ -1,11 +1,7 @@ import { Point, Vector } from '@flatten-js/core'; -export function rotatePoint( - point: Point, - rotateOrigin: Point, - angle: number, -): Point { - const vector = new Vector(rotateOrigin, point); - const rotatedVector = vector.rotate(angle); - return new Point(rotatedVector.x, rotatedVector.y); +export function rotatePoint(point: Point, rotateOrigin: Point, angle: number): Point { + const vector = new Vector(rotateOrigin, point); + const rotatedVector = vector.rotate(angle); + return new Point(rotatedVector.x, rotatedVector.y); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts b/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts index 57a8eedf..2f50410f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scale-point.ts @@ -1,11 +1,7 @@ import { Point, Vector } from '@flatten-js/core'; -export function scalePoint( - point: Point, - scaleOrigin: Point, - scaleFactor: number, -): Point { - const vector = new Vector(scaleOrigin, point); - const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1); - return new Point(point.x + scaledVector.x, point.y + scaledVector.y); +export function scalePoint(point: Point, scaleOrigin: Point, scaleFactor: number): Point { + const vector = new Vector(scaleOrigin, point); + const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1); + return new Point(point.x + scaledVector.x, point.y + scaledVector.y); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts index b2eb832b..e985ea34 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -1,9 +1,5 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; -import { - getGridEnabled, - getHighlightedEntityIds, - setHighlightedEntityIds, -} from '../state'; +import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; import { drawEntities } from './draw-functions'; import { getSceneVersion } from './scene-version'; import { queryEntitiesInBox } from './spatial-index'; @@ -130,7 +126,11 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted // bitmap would drag the grid along — always re-render while grid is on. - if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { + if ( + paramsChanged || + getGridEnabled() || + (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS) + ) { rebuildScene(drawController); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts index 39a2941a..7854266a 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-arc.ts @@ -10,24 +10,22 @@ import { ArcEntity } from '../entities/ArcEntity'; * @param startPoint */ export function sortPointsOnArc( - pointsOnArc: Point[], - centerPoint: Point, - startPoint: Point, + pointsOnArc: Point[], + centerPoint: Point, + startPoint: Point ): Point[] { - const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint); + const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint); - // Angles calculated from start point (0 degrees) and up - const pointsWithAngles: PointWithAngle[] = pointsOnArc.map(point => { - return { - point, - // Ensure all angles are between 0 (start point) and < 2PI, - // so we can sort them starting at the start point angle - angle: - (new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) % - (2 * Math.PI), - }; - }); - return sortBy(pointsWithAngles, [ - (pointWithAngle: PointWithAngle) => pointWithAngle.angle, - ]).map(pointsWithAngle => pointsWithAngle.point); + // Angles calculated from start point (0 degrees) and up + const pointsWithAngles: PointWithAngle[] = pointsOnArc.map((point) => { + return { + point, + // Ensure all angles are between 0 (start point) and < 2PI, + // so we can sort them starting at the start point angle + angle: (new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) % (2 * Math.PI), + }; + }); + return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map( + (pointsWithAngle) => pointsWithAngle.point + ); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts index d3c71b23..0cc9dde9 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/sort-points-on-circle.ts @@ -7,17 +7,14 @@ import type { PointWithAngle } from './helpers.types'; * @param pointsOnCircle * @param centerPoint */ -export function sortPointsOnCircle( - pointsOnCircle: Point[], - centerPoint: Point, -): Point[] { - const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map(point => { - return { - point, - angle: new Line(centerPoint, point).slope, - }; - }); - return sortBy(pointsWithAngles, [ - (pointWithAngle: PointWithAngle) => pointWithAngle.angle, - ]).map(pointsWithAngle => pointsWithAngle.point); +export function sortPointsOnCircle(pointsOnCircle: Point[], centerPoint: Point): Point[] { + const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map((point) => { + return { + point, + angle: new Line(centerPoint, point).slope, + }; + }); + return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map( + (pointsWithAngle) => pointsWithAngle.point + ); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/times.ts b/B07_DesignDetail/openwebcad/src/helpers/times.ts index aa57cb19..ec02784f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/times.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/times.ts @@ -1,12 +1,9 @@ -export function times( - num: number, - iterateeFunc: (i: number) => T = (i: number) => i as T, -): T[] { - let i = 0; - const items = []; - while (i < num) { - items.push(iterateeFunc(i)); - i++; - } - return items; +export function times(num: number, iterateeFunc: (i: number) => T = (i: number) => i as T): T[] { + let i = 0; + const items = []; + while (i < num) { + items.push(iterateeFunc(i)); + i++; + } + return items; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts b/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts index e7d96204..c3782990 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/track-hovered-snap-points.ts @@ -7,71 +7,64 @@ import { HOVERED_SNAP_POINT_TIME, MAX_MARKED_SNAP_POINTS } from '../App.consts'; * So we can show extra angle guides for the ones that are marked */ export function trackHoveredSnapPoint( - worldSnapPoint: SnapPoint | null, - worldHoveredSnapPoints: HoverPoint[], - setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void, - maxHoverDistance: number, - elapsedTime: number, + worldSnapPoint: SnapPoint | null, + worldHoveredSnapPoints: HoverPoint[], + setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void, + maxHoverDistance: number, + elapsedTime: number ) { - if (!worldSnapPoint) { - return; - } + if (!worldSnapPoint) { + return; + } - const lastHoveredPoint = worldHoveredSnapPoints.at(-1); - let newHoverSnapPoints: HoverPoint[]; + const lastHoveredPoint = worldHoveredSnapPoints.at(-1); + let newHoverSnapPoints: HoverPoint[]; - // Angle guide points should never be marked - if (lastHoveredPoint) { - if ( - pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) < - maxHoverDistance - ) { - // Last hovered snap point is still the current closest snap point - // Increase the hover time - newHoverSnapPoints = [ - ...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1), - { - ...lastHoveredPoint, - milliSecondsHovered: - lastHoveredPoint.milliSecondsHovered + elapsedTime, - }, - ]; - } else { - // The closest snap point has changed - // Check if the last snap point was hovered for long enough to be considered a marked snap point - if (lastHoveredPoint.milliSecondsHovered >= HOVERED_SNAP_POINT_TIME) { - // Append the new point to the list - newHoverSnapPoints = [ - ...worldHoveredSnapPoints, - { - snapPoint: worldSnapPoint, - milliSecondsHovered: elapsedTime, - }, - ]; - } else { - // Replace the last point with the new point - newHoverSnapPoints = [ - ...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1), - { - snapPoint: worldSnapPoint, - milliSecondsHovered: elapsedTime, - }, - ]; - } - } - } else { - // No snap points were hovered before - newHoverSnapPoints = [ - { - snapPoint: worldSnapPoint, - milliSecondsHovered: elapsedTime, - }, - ]; - } + // Angle guide points should never be marked + if (lastHoveredPoint) { + if (pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) < maxHoverDistance) { + // Last hovered snap point is still the current closest snap point + // Increase the hover time + newHoverSnapPoints = [ + ...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1), + { + ...lastHoveredPoint, + milliSecondsHovered: lastHoveredPoint.milliSecondsHovered + elapsedTime, + }, + ]; + } else { + // The closest snap point has changed + // Check if the last snap point was hovered for long enough to be considered a marked snap point + if (lastHoveredPoint.milliSecondsHovered >= HOVERED_SNAP_POINT_TIME) { + // Append the new point to the list + newHoverSnapPoints = [ + ...worldHoveredSnapPoints, + { + snapPoint: worldSnapPoint, + milliSecondsHovered: elapsedTime, + }, + ]; + } else { + // Replace the last point with the new point + newHoverSnapPoints = [ + ...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1), + { + snapPoint: worldSnapPoint, + milliSecondsHovered: elapsedTime, + }, + ]; + } + } + } else { + // No snap points were hovered before + newHoverSnapPoints = [ + { + snapPoint: worldSnapPoint, + milliSecondsHovered: elapsedTime, + }, + ]; + } - const newHoverSnapPointsTruncated = newHoverSnapPoints.slice( - 0, - MAX_MARKED_SNAP_POINTS, - ); - setHoveredSnapPoints(newHoverSnapPointsTruncated); + const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(0, MAX_MARKED_SNAP_POINTS); + setHoveredSnapPoints(newHoverSnapPointsTruncated); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/visibility.ts b/B07_DesignDetail/openwebcad/src/helpers/visibility.ts index f70854eb..9462b8f0 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/visibility.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/visibility.ts @@ -4,6 +4,7 @@ */ import { HtmlEvent } from '../App.types'; import type { Entity } from '../entities/Entity'; +import { notifyWindow } from '../state'; import { bumpSceneVersion } from './scene-version'; let hiddenEntityIds = new Set(); @@ -14,7 +15,7 @@ export const getHiddenEntityCount = (): number => hiddenEntityIds.size; function apply(ids: Set): void { hiddenEntityIds = ids; bumpSceneVersion(); - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); } /** 지정 객체를 숨긴다 */ diff --git a/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts b/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts index f3319ac2..9297f4cf 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/wrap-module.ts @@ -7,5 +7,5 @@ // 3 => 0 // 4 => 1 export function wrapModule(index: number, length: number) { - return (index + length) % length; + return (index + length) % length; } diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 9b2393b3..d8b7d70d 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -309,9 +309,15 @@ export const setActiveToolActor = ( triggerReactUpdate(StateVariable.activeTool); } }; +/** 화면에 알린다 — `window`가 없는 Node 시험에서는 통지를 건너뛴다. + * (`triggerReactUpdate`가 이미 같은 이유로 시험 환경을 건너뛴다.) */ +export const notifyWindow = (event: HtmlEvent) => { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(event)); +}; export const setLastStateInstructions = (newInstructions: string | null) => { lastStateInstructions = newInstructions; - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => { if (trackInUndoStack) { @@ -321,7 +327,7 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => bumpSceneVersion(); if (trackInUndoStack) { drawingDirty = true; - window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); + notifyWindow(HtmlEvent.DRAWING_CHANGED); } }; /** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */ @@ -348,7 +354,7 @@ export const setSelectedEntityIds = (newEntityIds: string[]) => { selectedEntityIds = newEntityIds; selectedEntityIdSet = new Set(newEntityIds); bumpSceneVersion(); // selection style (dashed) is baked into the scene cache - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setShouldDrawCursor = (newValue: boolean) => { shouldDrawCursor = newValue; @@ -462,19 +468,19 @@ export const setSnapEnabled = (enabled: boolean) => { setHoveredSnapPoints([]); setAngleGuideEntities([]); } - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setSnapTrackingEnabled = (enabled: boolean) => { snapTrackingEnabled = enabled; if (!enabled) { setHoveredSnapPoints([]); } - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setGridEnabled = (enabled: boolean) => { gridEnabled = enabled; bumpSceneVersion(); - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); }; export const setDesignMeta = (newMeta: DesignMeta | null) => { designMeta = newMeta; @@ -556,7 +562,7 @@ export function undo() { updateStates(undoState); drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다 - window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); + notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function redo() { @@ -565,7 +571,7 @@ export function redo() { updateStates(redoState); drawingDirty = true; - window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED)); + notifyWindow(HtmlEvent.DRAWING_CHANGED); } export function triggerReactUpdate(variable: StateVariable) { @@ -577,5 +583,5 @@ export function triggerReactUpdate(variable: StateVariable) { return; } - window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE)); + notifyWindow(HtmlEvent.UPDATE_STATE); } diff --git a/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts index ff4c7fd0..1a38834a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-bottom-tool.ts @@ -1,8 +1,8 @@ -import {Tool} from '../tools'; -import {createMachine} from 'xstate'; -import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; -import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; -import type {Entity} from "../entities/Entity.ts"; +import { Tool } from '../tools'; +import { createMachine } from 'xstate'; +import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts'; +import type { Entity } from '../entities/Entity.ts'; /** * AlignBottom tool state machine @@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts"; * When the user presses enter, the selected entities are bottom aligned */ export const alignBottomToolStateMachine = createMachine( - GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM), - GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { - entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin); - }) + GET_ALIGN_TOOL_STATE(Tool.ALIGN_BOTTOM), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(0, boundingBox.minY - entity.getBoundingBox().ymin); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts index 9a7c7979..4d5b6855 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-center-horizontal-tool.ts @@ -1,9 +1,9 @@ -import {Tool} from '../tools'; -import {createMachine} from 'xstate'; -import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; -import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; -import type {Entity} from "../entities/Entity.ts"; -import {middle} from "../helpers/middle.ts"; +import { Tool } from '../tools'; +import { createMachine } from 'xstate'; +import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts'; +import type { Entity } from '../entities/Entity.ts'; +import { middle } from '../helpers/middle.ts'; /** * AlignCenterHorizontal tool state machine @@ -12,11 +12,11 @@ import {middle} from "../helpers/middle.ts"; * When the user presses enter, the selected entities are center horizontal aligned */ export const alignCenterHorizontalToolStateMachine = createMachine( - GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL), - GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { - const entityBoundingBox = entity.getBoundingBox(); - const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX); - const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax); - entity.move(centerBoundingBoxX - centerEntityX, 0); - }) + GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_HORIZONTAL), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + const entityBoundingBox = entity.getBoundingBox(); + const centerBoundingBoxX = middle(boundingBox.minX, boundingBox.maxX); + const centerEntityX = middle(entityBoundingBox.xmin, entityBoundingBox.xmax); + entity.move(centerBoundingBoxX - centerEntityX, 0); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts index f228e4b6..2a74aa0e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-left-tool.ts @@ -1,8 +1,8 @@ -import {Tool} from '../tools'; -import {createMachine} from 'xstate'; -import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; -import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; -import type {Entity} from "../entities/Entity.ts"; +import { Tool } from '../tools'; +import { createMachine } from 'xstate'; +import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts'; +import type { Entity } from '../entities/Entity.ts'; /** * AlignLeft tool state machine @@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts"; * When the user presses enter, the selected entities are left aligned */ export const alignLeftToolStateMachine = createMachine( - GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT), - GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { - entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0); - }) + GET_ALIGN_TOOL_STATE(Tool.ALIGN_LEFT), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(boundingBox.minX - entity.getBoundingBox().xmin, 0); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts index 6e58ee36..1f835d32 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-middle-vertical-tool.ts @@ -1,9 +1,9 @@ -import {Tool} from '../tools'; -import {createMachine} from 'xstate'; -import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; -import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; -import type {Entity} from "../entities/Entity.ts"; -import {middle} from "../helpers/middle.ts"; +import { Tool } from '../tools'; +import { createMachine } from 'xstate'; +import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts'; +import type { Entity } from '../entities/Entity.ts'; +import { middle } from '../helpers/middle.ts'; /** * AlignCenterVertical tool state machine @@ -12,11 +12,11 @@ import {middle} from "../helpers/middle.ts"; * When the user presses enter, the selected entities are center vertical aligned */ export const alignCenterVerticalToolStateMachine = createMachine( - GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL), - GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { - const entityBoundingBox = entity.getBoundingBox(); - const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY); - const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax); - entity.move(0, centerBoundingBoxY - centerEntityY); - }) + GET_ALIGN_TOOL_STATE(Tool.ALIGN_CENTER_VERTICAL), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + const entityBoundingBox = entity.getBoundingBox(); + const centerBoundingBoxY = middle(boundingBox.minY, boundingBox.maxY); + const centerEntityY = middle(entityBoundingBox.ymin, entityBoundingBox.ymax); + entity.move(0, centerBoundingBoxY - centerEntityY); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts index 7e1bca6a..b6496f07 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-right-tool.ts @@ -1,8 +1,8 @@ -import {Tool} from '../tools'; -import {createMachine} from 'xstate'; -import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; -import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; -import type {Entity} from "../entities/Entity.ts"; +import { Tool } from '../tools'; +import { createMachine } from 'xstate'; +import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts'; +import type { Entity } from '../entities/Entity.ts'; /** * AlignRight tool state machine @@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts"; * When the user presses enter, the selected entities are right aligned */ export const alignRightToolStateMachine = createMachine( - GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT), - GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { - entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0); - }) + GET_ALIGN_TOOL_STATE(Tool.ALIGN_RIGHT), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(boundingBox.maxX - entity.getBoundingBox().xmax, 0); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts index 53e03856..0f1c2c49 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts @@ -1,6 +1,9 @@ -import {assign, type MachineContext, sendTo} from 'xstate'; -import type {Entity} from '../entities/Entity.ts'; -import {type BoundingBox, getBoundingBoxOfMultipleEntities,} from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { assign, type MachineContext, sendTo } from 'xstate'; +import type { Entity } from '../entities/Entity.ts'; +import { + type BoundingBox, + getBoundingBoxOfMultipleEntities, +} from '../helpers/get-bounding-box-of-multiple-entities.ts'; import { getSelectedEntities, getSelectedEntityIds, @@ -9,9 +12,15 @@ import { setSelectedEntityIds, setShouldDrawHelpers, } from '../state.ts'; -import type {Tool} from '../tools.ts'; -import {selectToolStateMachine} from './select-tool.ts'; -import type {DrawEvent, KeyboardEnterEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types.ts'; +import type { Tool } from '../tools.ts'; +import { selectToolStateMachine } from './select-tool.ts'; +import type { + DrawEvent, + KeyboardEnterEvent, + MouseClickEvent, + StateEvent, + ToolContext, +} from './tool.types.ts'; export interface AlignContext extends ToolContext {} diff --git a/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts b/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts index 844366e2..7213711f 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-top-tool.ts @@ -1,8 +1,8 @@ -import {Tool} from '../tools'; -import {createMachine} from 'xstate'; -import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts"; -import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts"; -import type {Entity} from "../entities/Entity.ts"; +import { Tool } from '../tools'; +import { createMachine } from 'xstate'; +import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts'; +import type { Entity } from '../entities/Entity.ts'; /** * AlignTop tool state machine @@ -11,8 +11,8 @@ import type {Entity} from "../entities/Entity.ts"; * When the user presses enter, the selected entities are top aligned */ export const alignTopToolStateMachine = createMachine( - GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP), - GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { - entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY)); - }) + GET_ALIGN_TOOL_STATE(Tool.ALIGN_TOP), + GET_ALIGN_ACTION((entity: Entity, boundingBox: BoundingBox) => { + entity.move(0, -(entity.getBoundingBox().ymax - boundingBox.maxY)); + }) ); diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts index 8e094c2e..174c2603 100644 --- a/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/dimension-radial-tools.ts @@ -97,7 +97,7 @@ export const dimAngularToolStateMachine = createSequenceTool({ const startAngle = Math.atan2(first.y - vertex.y, first.x - vertex.x); const endAngle = Math.atan2(second.y - vertex.y, second.x - vertex.x); - const sweep = ((endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI)); + const sweep = (endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI); const midAngle = startAngle + sweep / 2; const degrees = (sweep * 180) / Math.PI; diff --git a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts index 752e7895..829a3454 100644 --- a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts @@ -1,9 +1,9 @@ -import {type Point, Vector} from '@flatten-js/core'; -import {assign, createMachine, sendTo} from 'xstate'; -import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS} from '../App.consts.ts'; -import type {Entity} from '../entities/Entity'; -import {LineEntity} from "../entities/LineEntity.ts"; -import {getPointFromEvent} from "../helpers/get-point-from-event.ts"; +import { type Point, Vector } from '@flatten-js/core'; +import { assign, createMachine, sendTo } from 'xstate'; +import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS } from '../App.consts.ts'; +import type { Entity } from '../entities/Entity'; +import { LineEntity } from '../entities/LineEntity.ts'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; import { addEntities, getActiveLayerId, @@ -14,9 +14,9 @@ import { setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import {CopyAction} from './copy-tool.ts'; -import {selectToolStateMachine} from './select-tool.ts'; +import { Tool } from '../tools'; +import { CopyAction } from './copy-tool.ts'; +import { selectToolStateMachine } from './select-tool.ts'; import type { AbsolutePointInputEvent, DrawEvent, diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 0b41da5e..7ef246f2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -1,48 +1,48 @@ -import type {Point} from '@flatten-js/core'; +import type { Point } from '@flatten-js/core'; import { - addEntities, - getActiveLayerId, - getSelectedEntities, - getSelectedEntityIds, - setAngleGuideOriginPoint, - setGhostHelperEntities, - setSelectedEntityIds, - setShouldDrawHelpers, + addEntities, + getActiveLayerId, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; -import {assign, createMachine, sendTo} from 'xstate'; -import {selectToolStateMachine} from './select-tool'; -import type {Entity} from '../entities/Entity'; -import {compact} from 'es-toolkit'; -import {LineEntity} from '../entities/LineEntity'; -import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts'; -import {moveEntities} from './move-tool.helpers'; +import { Tool } from '../tools'; +import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { assign, createMachine, sendTo } from 'xstate'; +import { selectToolStateMachine } from './select-tool'; +import type { Entity } from '../entities/Entity'; +import { compact } from 'es-toolkit'; +import { LineEntity } from '../entities/LineEntity'; +import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts'; +import { moveEntities } from './move-tool.helpers'; export interface CopyContext extends ToolContext { - startPoint: Point | null; - originalSelectedEntities: Entity[]; - copiedEntities: Entity[]; - lastDrawLocation: Point | null; + startPoint: Point | null; + originalSelectedEntities: Entity[]; + copiedEntities: Entity[]; + lastDrawLocation: Point | null; } export enum CopyState { - INIT = 'INIT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', - WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT', - WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT', + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_START_COPY_POINT = 'WAITING_FOR_START_COPY_POINT', + WAITING_FOR_END_COPY_POINT = 'WAITING_FOR_END_COPY_POINT', } export enum CopyAction { - INIT_COPY_TOOL = 'INIT_COPY_TOOL', - ENABLE_HELPERS = 'ENABLE_HELPERS', - RECORD_START_POINT = 'RECORD_START_POINT', - COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY', - DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES', - COPY_SELECTION = 'COPY_SELECTION', - DESELECT_ENTITIES = 'DESELECT_ENTITIES', - RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', + INIT_COPY_TOOL = 'INIT_COPY_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_START_POINT = 'RECORD_START_POINT', + COPY_SELECTION_BEFORE_COPY = 'COPY_SELECTION_BEFORE_COPY', + DRAW_TEMP_COPY_ENTITIES = 'DRAW_TEMP_COPY_ENTITIES', + COPY_SELECTION = 'COPY_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } /** @@ -55,242 +55,227 @@ export enum CopyAction { * When the user clicks again, the end point is selected and the entities are copied to the new location */ export const copyToolStateMachine = createMachine( - { - types: {} as { - context: CopyContext; - events: StateEvent; - }, - context: { - startPoint: null, - originalSelectedEntities: [], - copiedEntities: [], - lastDrawLocation: null, - type: Tool.COPY, - }, - initial: CopyState.INIT, - states: { - [CopyState.INIT]: { - description: 'Initializing the copy tool', - always: { - actions: CopyAction.INIT_COPY_TOOL, - target: CopyState.CHECK_SELECTION, - }, - }, - [CopyState.CHECK_SELECTION]: { - description: 'Check if there is something selected', - always: [ - { - guard: () => { - return getSelectedEntityIds().length > 0; - }, - target: CopyState.WAITING_FOR_START_COPY_POINT, - }, - { - guard: () => { - return getSelectedEntityIds().length === 0; - }, - target: CopyState.WAITING_FOR_SELECTION, - }, - ], - }, - [CopyState.WAITING_FOR_SELECTION]: { - description: 'Select what you want to copy', - meta: { - instructions: 'Select what you want to copy, then ENTER', - }, - invoke: { - id: 'selectToolInsideTheCopyTool', - src: selectToolStateMachine, - onDone: { - actions: assign(() => { - return { - startPoint: null, - }; - }), - target: CopyState.CHECK_SELECTION, - }, - }, - on: { - MOUSE_CLICK: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { - return event; - }), - }, - ESC: { - actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL], - }, - ENTER: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { - return event; - }), - }, - DRAW: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { - return event; - }), - }, - }, - }, - [CopyState.WAITING_FOR_START_COPY_POINT]: { - description: 'Select the start of the copy line', - meta: { - instructions: 'Select the start of the copy line', - }, - always: { - actions: CopyAction.ENABLE_HELPERS, - }, - on: { - MOUSE_CLICK: { - actions: [ - CopyAction.RECORD_START_POINT, - CopyAction.COPY_SELECTION_BEFORE_COPY, - ], - target: CopyState.WAITING_FOR_END_COPY_POINT, - }, - ESC: { - actions: CopyAction.DESELECT_ENTITIES, - target: CopyState.INIT, - }, - }, - }, - [CopyState.WAITING_FOR_END_COPY_POINT]: { - description: 'Select the end of the copy line', - meta: { - instructions: 'Select the end of the copy line', - }, - on: { - DRAW: { - actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES], - }, - MOUSE_CLICK: { - actions: [CopyAction.COPY_SELECTION], - target: CopyState.WAITING_FOR_END_COPY_POINT, - }, - ESC: { - actions: CopyAction.DESELECT_ENTITIES, - target: CopyState.INIT, - }, - }, - }, - }, - }, - { - actions: { - [CopyAction.INIT_COPY_TOOL]: assign(() => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - setAngleGuideOriginPoint(null); - return { - startPoint: null, - originalSelectedEntities: [], - copiedEntities: [], - lastDrawLocation: null, - }; - }), - [CopyAction.ENABLE_HELPERS]: () => { - setShouldDrawHelpers(true); - }, - [CopyAction.RECORD_START_POINT]: assign(({ event }) => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - startPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }), - [CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => { - const selectedEntities = getSelectedEntities(); + { + types: {} as { + context: CopyContext; + events: StateEvent; + }, + context: { + startPoint: null, + originalSelectedEntities: [], + copiedEntities: [], + lastDrawLocation: null, + type: Tool.COPY, + }, + initial: CopyState.INIT, + states: { + [CopyState.INIT]: { + description: 'Initializing the copy tool', + always: { + actions: CopyAction.INIT_COPY_TOOL, + target: CopyState.CHECK_SELECTION, + }, + }, + [CopyState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: CopyState.WAITING_FOR_START_COPY_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: CopyState.WAITING_FOR_SELECTION, + }, + ], + }, + [CopyState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to copy', + meta: { + instructions: 'Select what you want to copy, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheCopyTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + startPoint: null, + }; + }), + target: CopyState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [CopyAction.DESELECT_ENTITIES, CopyAction.INIT_COPY_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheCopyTool', ({ event }) => { + return event; + }), + }, + }, + }, + [CopyState.WAITING_FOR_START_COPY_POINT]: { + description: 'Select the start of the copy line', + meta: { + instructions: 'Select the start of the copy line', + }, + always: { + actions: CopyAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + ESC: { + actions: CopyAction.DESELECT_ENTITIES, + target: CopyState.INIT, + }, + }, + }, + [CopyState.WAITING_FOR_END_COPY_POINT]: { + description: 'Select the end of the copy line', + meta: { + instructions: 'Select the end of the copy line', + }, + on: { + DRAW: { + actions: [CopyAction.DRAW_TEMP_COPY_ENTITIES], + }, + MOUSE_CLICK: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + ESC: { + actions: CopyAction.DESELECT_ENTITIES, + target: CopyState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [CopyAction.INIT_COPY_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + originalSelectedEntities: [], + copiedEntities: [], + lastDrawLocation: null, + }; + }), + [CopyAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [CopyAction.RECORD_START_POINT]: assign(({ event }) => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + startPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); - // Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides - setGhostHelperEntities(selectedEntities); + // Copy the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); - // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being copied and the original entities also are used for snap points / angle guides - setSelectedEntityIds([]); - return { - startPoint: context.startPoint, - // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action - originalSelectedEntities: compact( - selectedEntities.map(entity => entity.clone()), - ), - copiedEntities: selectedEntities, - }; - }), - [CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error( - '[COPY] Calling draw temp copy line without a start point', - ); - } + setSelectedEntityIds([]); + return { + startPoint: context.startPoint, + // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action + originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + copiedEntities: selectedEntities, + }; + }), + [CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error('[COPY] Calling draw temp copy line without a start point'); + } - const endPointTemp = ( - event as DrawEvent - ).drawController.getWorldMouseLocation(); + const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); - // Copy the entities to the new location - // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; - const movedEntities = context.originalSelectedEntities.map(entity => - entity.clone(), - ); - moveEntities( - movedEntities, - endPointTemp.x - context.startPoint.x, - endPointTemp.y - context.startPoint.y, - ); + // Copy the entities to the new location + // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; + const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + moveEntities( + movedEntities, + endPointTemp.x - context.startPoint.x, + endPointTemp.y - context.startPoint.y + ); - // // Draw a dashed line between the start copy point and the current mouse location - const activeCopyLine = new LineEntity( - getActiveLayerId(), - context.startPoint as Point, - endPointTemp, - ); - activeCopyLine.lineColor = GUIDE_LINE_COLOR; - activeCopyLine.lineWidth = GUIDE_LINE_WIDTH; - activeCopyLine.lineDash = GUIDE_LINE_STYLE; - setGhostHelperEntities([activeCopyLine, ...movedEntities]); - }, - [CopyAction.COPY_SELECTION]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error( - '[COPY] Calling copy selection without a start point', - ); - } + // // Draw a dashed line between the start copy point and the current mouse location + const activeCopyLine = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + endPointTemp + ); + activeCopyLine.lineColor = GUIDE_LINE_COLOR; + activeCopyLine.lineWidth = GUIDE_LINE_WIDTH; + activeCopyLine.lineDash = GUIDE_LINE_STYLE; + setGhostHelperEntities([activeCopyLine, ...movedEntities]); + }, + [CopyAction.COPY_SELECTION]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error('[COPY] Calling copy selection without a start point'); + } - // Copy the entities one final time - const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; - const copiedEntities = context.originalSelectedEntities.map(entity => - entity.clone(), - ); - moveEntities( - copiedEntities, - currentEndPoint.x - context.startPoint.x, - currentEndPoint.y - context.startPoint.y, - ); + // Copy the entities one final time + const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; + const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + moveEntities( + copiedEntities, + currentEndPoint.x - context.startPoint.x, + currentEndPoint.y - context.startPoint.y + ); - // Switch the copied entities back from the ghost helper entities to the real entities - addEntities([...context.originalSelectedEntities, ...copiedEntities], true); - }, - [CopyAction.DESELECT_ENTITIES]: assign(() => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - [CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { - addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - ...selectToolStateMachine.implementations.actions, - }, - }, + // Switch the copied entities back from the ghost helper entities to the real entities + addEntities([...context.originalSelectedEntities, ...copiedEntities], true); + }, + [CopyAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [CopyAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts index 69be6e07..5e052f81 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/basic-draw-tools.ts @@ -9,7 +9,13 @@ import { } from '../../helpers/geometry/shape-points'; import { addEntities, getActiveLayerId } from '../../state'; import { Tool } from '../../tools'; -import { arcEntity, lineEntity, pointEntity, polyLineEntity, styled } from '../factories/entity-factory'; +import { + arcEntity, + lineEntity, + pointEntity, + polyLineEntity, + styled, +} from '../factories/entity-factory'; import { createSequenceTool } from '../factories/sequence-tool'; export const plineToolStateMachine = createSequenceTool({ @@ -138,7 +144,11 @@ export const donutToolStateMachine = createSequenceTool({ }, }); -function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters[0]): Entity[] { +function donutEntities( + innerDiameter: number, + outerDiameter: number, + center: Parameters[0] +): Entity[] { const circles: Entity[] = []; for (const diameter of [innerDiameter, outerDiameter]) { if (diameter > 0) { diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts index 8199865f..19ddd7c3 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/divide-tools.ts @@ -1,6 +1,10 @@ /** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */ import { toast } from 'react-toastify'; -import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity'; +import { + dividePoints, + measurePoints, + sampleEntityPoints, +} from '../../helpers/geometry/sample-entity'; import { addEntities } from '../../state'; import { Tool } from '../../tools'; import { pointEntity } from '../factories/entity-factory'; diff --git a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts index c0de937a..a0db5784 100644 --- a/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/draw/fill-tools.ts @@ -57,9 +57,7 @@ export const hatchToolStateMachine = createSequenceTool({ export const gradientToolStateMachine = createSequenceTool({ tool: Tool.GRADIENT, helpers: false, - steps: [ - { kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' }, - ], + steps: [{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' }], commit: (input) => { const loop = loopFromSelection(input); if (!loop.length) return; @@ -80,9 +78,7 @@ export const gradientToolStateMachine = createSequenceTool({ export const boundaryToolStateMachine = createSequenceTool({ tool: Tool.BOUNDARY, helpers: false, - steps: [ - { kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' }, - ], + steps: [{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' }], commit: (input) => { const loop = loopFromSelection(input); if (!loop.length) return; diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts index 22943e86..9238c284 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.helpers.ts @@ -1,15 +1,15 @@ -import {type Circle, Point, type Segment} from '@flatten-js/core'; -import {compact} from 'es-toolkit'; -import {ArcEntity} from '../entities/ArcEntity'; -import type {CircleEntity} from '../entities/CircleEntity'; -import type {Entity} from '../entities/Entity'; -import type {LineEntity} from '../entities/LineEntity'; -import {findNeighboringPointsOnArc} from '../helpers/find-neighboring-points-on-arc'; -import {findNeighboringPointsOnCircle} from '../helpers/find-neighboring-points-on-circle'; -import {findNeighboringPointsOnLine} from '../helpers/find-neighboring-points-on-line'; -import {getAngleWithXAxis} from '../helpers/get-angle-with-x-axis.ts'; -import {isPointEqual} from '../helpers/is-point-equal'; -import {addEntities, deleteEntities, getActiveLayerId} from '../state'; +import { type Circle, Point, type Segment } from '@flatten-js/core'; +import { compact } from 'es-toolkit'; +import { ArcEntity } from '../entities/ArcEntity'; +import type { CircleEntity } from '../entities/CircleEntity'; +import type { Entity } from '../entities/Entity'; +import type { LineEntity } from '../entities/LineEntity'; +import { findNeighboringPointsOnArc } from '../helpers/find-neighboring-points-on-arc'; +import { findNeighboringPointsOnCircle } from '../helpers/find-neighboring-points-on-circle'; +import { findNeighboringPointsOnLine } from '../helpers/find-neighboring-points-on-line'; +import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; +import { isPointEqual } from '../helpers/is-point-equal'; +import { addEntities, deleteEntities, getActiveLayerId } from '../state'; export function getAllIntersectionPoints(entity: Entity, entities: Entity[]): Point[] { // TODO see if we need to make this list unique diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts index 8aa8d85c..3d21b7f5 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.test.ts @@ -1,13 +1,13 @@ -import {type Arc, Point} from '@flatten-js/core'; -import {describe, expect, it} from 'vitest'; -import {TO_DEGREES, TO_RADIANS} from '../App.consts.ts'; -import type {ArcEntity} from '../entities/ArcEntity.ts'; -import {CircleEntity} from '../entities/CircleEntity.ts'; -import {EntityName} from '../entities/Entity.ts'; -import {RectangleEntity} from '../entities/RectangleEntity.ts'; -import {getEntities, setEntities} from '../state.ts'; -import {eraseCircleSegment, getAllIntersectionPoints,} from './eraser-tool.helpers.ts'; -import {handleMouseClick} from './eraser-tool.ts'; +import { type Arc, Point } from '@flatten-js/core'; +import { describe, expect, it } from 'vitest'; +import { TO_DEGREES, TO_RADIANS } from '../App.consts.ts'; +import type { ArcEntity } from '../entities/ArcEntity.ts'; +import { CircleEntity } from '../entities/CircleEntity.ts'; +import { EntityName } from '../entities/Entity.ts'; +import { RectangleEntity } from '../entities/RectangleEntity.ts'; +import { getEntities, setEntities } from '../state.ts'; +import { eraseCircleSegment, getAllIntersectionPoints } from './eraser-tool.helpers.ts'; +import { handleMouseClick } from './eraser-tool.ts'; describe('erase-tool', () => { /** diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts index ee7ce6d1..a376eb7c 100644 --- a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.helpers.ts @@ -1,4 +1,4 @@ -import {Box, type Point} from '@flatten-js/core'; +import { Box, type Point } from '@flatten-js/core'; export function getContainRectangleInsideRectangle( imageWidth: number, diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts index 44350c7a..81f9b313 100644 --- a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts @@ -1,245 +1,250 @@ -import type {Point} from '@flatten-js/core'; +import type { Point } from '@flatten-js/core'; import { - addEntities, - getActiveLayerId, - setActiveToolActor, - setAngleGuideEntities, - setAngleGuideOriginPoint, - setGhostHelperEntities, - setSelectedEntityIds, - setShouldDrawHelpers, + addEntities, + getActiveLayerId, + setActiveToolActor, + setAngleGuideEntities, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import {Actor, assign, createMachine} from 'xstate'; -import {ActorEvent, type DrawEvent, type FileSelectedEvent, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext,} from './tool.types'; -import {ImageEntity} from '../entities/ImageEntity'; -import {getContainRectangleInsideRectangle} from './image-import-tool.helpers'; -import {RectangleEntity} from '../entities/RectangleEntity'; -import {selectToolStateMachine} from './select-tool'; -import {boxToPolygon, twoPointBoxToPolygon} from '../helpers/box-to-polygon'; -import {isPointEqual} from '../helpers/is-point-equal.ts'; -import {getPointFromEvent} from '../helpers/get-point-from-event.ts'; +import { Tool } from '../tools'; +import { Actor, assign, createMachine } from 'xstate'; +import { + ActorEvent, + type DrawEvent, + type FileSelectedEvent, + type MouseClickEvent, + type PointInputEvent, + type StateEvent, + type ToolContext, +} from './tool.types'; +import { ImageEntity } from '../entities/ImageEntity'; +import { getContainRectangleInsideRectangle } from './image-import-tool.helpers'; +import { RectangleEntity } from '../entities/RectangleEntity'; +import { selectToolStateMachine } from './select-tool'; +import { boxToPolygon, twoPointBoxToPolygon } from '../helpers/box-to-polygon'; +import { isPointEqual } from '../helpers/is-point-equal.ts'; +import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; export interface ImageImportContext extends ToolContext { - startPoint: Point | null; - imageElement: HTMLImageElement | null; + startPoint: Point | null; + imageElement: HTMLImageElement | null; } export enum ImageImportState { - INIT = 'INIT', - WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA', - WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', - WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', + INIT = 'INIT', + WAIT_FOR_IMAGE_DATA = 'WAIT_FOR_IMAGE_DATA', + WAITING_FOR_START_POINT = 'WAITING_FOR_START_POINT', + WAITING_FOR_END_POINT = 'WAITING_FOR_END_POINT', } export enum ImageImportAction { - INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL', - STORE_IMAGE_DATA = 'STORE_IMAGE_DATA', - RECORD_START_POINT = 'RECORD_START_POINT', - DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT', - DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT', - SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', + INIT_IMAGE_IMPORT_TOOL = 'INIT_IMAGE_IMPORT_TOOL', + STORE_IMAGE_DATA = 'STORE_IMAGE_DATA', + RECORD_START_POINT = 'RECORD_START_POINT', + DRAW_TEMP_IMAGE_IMPORT = 'DRAW_TEMP_IMAGE_IMPORT', + DRAW_FINAL_IMAGE_IMPORT = 'DRAW_FINAL_IMAGE_IMPORT', + SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', } export const imageImportToolStateMachine = createMachine( - { - types: {} as { - context: ImageImportContext; - events: StateEvent; - }, - context: { - type: Tool.IMAGE_IMPORT, - startPoint: null, - imageElement: null, - }, - initial: ImageImportState.INIT, - states: { - [ImageImportState.INIT]: { - description: 'Initializing the imageImport tool', - always: { - actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL, - target: ImageImportState.WAIT_FOR_IMAGE_DATA, - }, - }, - [ImageImportState.WAIT_FOR_IMAGE_DATA]: { - description: 'Select an image file to import', - meta: { - instructions: 'Select an image file to import', - }, - on: { - [ActorEvent.FILE_SELECTED]: { - actions: ImageImportAction.STORE_IMAGE_DATA, - target: ImageImportState.WAITING_FOR_START_POINT, - }, - ESC: { - actions: ImageImportAction.SWITCH_TO_SELECT_TOOL, - }, - }, - }, - [ImageImportState.WAITING_FOR_START_POINT]: { - description: 'Select the start point of the imageImport', - meta: { - instructions: 'Select the start point of the imageImport', - }, - on: { - MOUSE_CLICK: { - actions: ImageImportAction.RECORD_START_POINT, - target: ImageImportState.WAITING_FOR_END_POINT, - }, - ABSOLUTE_POINT_INPUT: { - actions: ImageImportAction.RECORD_START_POINT, - target: ImageImportState.WAITING_FOR_END_POINT, - }, - }, - }, - [ImageImportState.WAITING_FOR_END_POINT]: { - description: 'Select the end point of the imageImport', - meta: { - instructions: 'Select the end point of the imageImport', - }, - on: { - DRAW: { - actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT, - }, - MOUSE_CLICK: { - actions: [ - ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, - ImageImportAction.INIT_IMAGE_IMPORT_TOOL, - ImageImportAction.SWITCH_TO_SELECT_TOOL, - ], - }, - NUMBER_INPUT: { - actions: [ - ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, - ImageImportAction.INIT_IMAGE_IMPORT_TOOL, - ImageImportAction.SWITCH_TO_SELECT_TOOL, - ], - }, - ABSOLUTE_POINT_INPUT: { - actions: [ - ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, - ImageImportAction.INIT_IMAGE_IMPORT_TOOL, - ImageImportAction.SWITCH_TO_SELECT_TOOL, - ], - }, - RELATIVE_POINT_INPUT: { - actions: [ - ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, - ImageImportAction.INIT_IMAGE_IMPORT_TOOL, - ImageImportAction.SWITCH_TO_SELECT_TOOL, - ], - }, - ESC: { - actions: ImageImportAction.SWITCH_TO_SELECT_TOOL, - }, - }, - }, - }, - }, - { - actions: { - [ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => { - setShouldDrawHelpers(true); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - setAngleGuideOriginPoint(null); - return { - startPoint: null, - imageElement: null, - }; - }), - [ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => { - return { - imageElement: (event as FileSelectedEvent).image, - }; - }), - [ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => { - const startPoint = getPointFromEvent(null, event as PointInputEvent); - setAngleGuideOriginPoint(startPoint); - return { - ...context, - startPoint: startPoint, - }; - }), - [ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error( - '[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT', - ); - } - if (!context.imageElement) { - throw new Error( - '[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT', - ); - } - if ( - isPointEqual( - context.startPoint, - (event as DrawEvent).drawController.getWorldMouseLocation(), - ) - ) { - return; // Can't draw an image that is 0 pixels wide - } - const endPoint = getPointFromEvent( - context.startPoint, - event as PointInputEvent, - ); - const containRectangle = getContainRectangleInsideRectangle( - context.imageElement.naturalWidth, - context.imageElement.naturalHeight, - context.startPoint, - endPoint, - ); - if (!containRectangle) { - return; - } - const activeImage = new ImageEntity( - getActiveLayerId(), - context.imageElement, - containRectangle.low, - containRectangle.high, - 0, - ); - const draggedRectangle = new RectangleEntity( - getActiveLayerId(), - twoPointBoxToPolygon(context.startPoint, endPoint), - ); - setGhostHelperEntities([activeImage]); - setAngleGuideEntities([draggedRectangle]); - }, - [ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error( - '[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT', - ); - } - if (!context.imageElement) { - throw new Error( - '[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT', - ); - } + { + types: {} as { + context: ImageImportContext; + events: StateEvent; + }, + context: { + type: Tool.IMAGE_IMPORT, + startPoint: null, + imageElement: null, + }, + initial: ImageImportState.INIT, + states: { + [ImageImportState.INIT]: { + description: 'Initializing the imageImport tool', + always: { + actions: ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + target: ImageImportState.WAIT_FOR_IMAGE_DATA, + }, + }, + [ImageImportState.WAIT_FOR_IMAGE_DATA]: { + description: 'Select an image file to import', + meta: { + instructions: 'Select an image file to import', + }, + on: { + [ActorEvent.FILE_SELECTED]: { + actions: ImageImportAction.STORE_IMAGE_DATA, + target: ImageImportState.WAITING_FOR_START_POINT, + }, + ESC: { + actions: ImageImportAction.SWITCH_TO_SELECT_TOOL, + }, + }, + }, + [ImageImportState.WAITING_FOR_START_POINT]: { + description: 'Select the start point of the imageImport', + meta: { + instructions: 'Select the start point of the imageImport', + }, + on: { + MOUSE_CLICK: { + actions: ImageImportAction.RECORD_START_POINT, + target: ImageImportState.WAITING_FOR_END_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: ImageImportAction.RECORD_START_POINT, + target: ImageImportState.WAITING_FOR_END_POINT, + }, + }, + }, + [ImageImportState.WAITING_FOR_END_POINT]: { + description: 'Select the end point of the imageImport', + meta: { + instructions: 'Select the end point of the imageImport', + }, + on: { + DRAW: { + actions: ImageImportAction.DRAW_TEMP_IMAGE_IMPORT, + }, + MOUSE_CLICK: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + NUMBER_INPUT: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + ABSOLUTE_POINT_INPUT: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + RELATIVE_POINT_INPUT: { + actions: [ + ImageImportAction.DRAW_FINAL_IMAGE_IMPORT, + ImageImportAction.INIT_IMAGE_IMPORT_TOOL, + ImageImportAction.SWITCH_TO_SELECT_TOOL, + ], + }, + ESC: { + actions: ImageImportAction.SWITCH_TO_SELECT_TOOL, + }, + }, + }, + }, + }, + { + actions: { + [ImageImportAction.INIT_IMAGE_IMPORT_TOOL]: assign(() => { + setShouldDrawHelpers(true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + imageElement: null, + }; + }), + [ImageImportAction.STORE_IMAGE_DATA]: assign(({ event }) => { + return { + imageElement: (event as FileSelectedEvent).image, + }; + }), + [ImageImportAction.RECORD_START_POINT]: assign(({ context, event }) => { + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { + ...context, + startPoint: startPoint, + }; + }), + [ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT' + ); + } + if (!context.imageElement) { + throw new Error( + '[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT' + ); + } + if ( + isPointEqual( + context.startPoint, + (event as DrawEvent).drawController.getWorldMouseLocation() + ) + ) { + return; // Can't draw an image that is 0 pixels wide + } + const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); + const containRectangle = getContainRectangleInsideRectangle( + context.imageElement.naturalWidth, + context.imageElement.naturalHeight, + context.startPoint, + endPoint + ); + if (!containRectangle) { + return; + } + const activeImage = new ImageEntity( + getActiveLayerId(), + context.imageElement, + containRectangle.low, + containRectangle.high, + 0 + ); + const draggedRectangle = new RectangleEntity( + getActiveLayerId(), + twoPointBoxToPolygon(context.startPoint, endPoint) + ); + setGhostHelperEntities([activeImage]); + setAngleGuideEntities([draggedRectangle]); + }, + [ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error( + '[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT' + ); + } + if (!context.imageElement) { + throw new Error( + '[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT' + ); + } - const containRectangle = getContainRectangleInsideRectangle( - context.imageElement.naturalWidth, - context.imageElement.naturalHeight, - context.startPoint, - (event as MouseClickEvent).worldMouseLocation, - ); + const containRectangle = getContainRectangleInsideRectangle( + context.imageElement.naturalWidth, + context.imageElement.naturalHeight, + context.startPoint, + (event as MouseClickEvent).worldMouseLocation + ); - if (!containRectangle) { - return; - } + if (!containRectangle) { + return; + } - const activeImage = new ImageEntity( - getActiveLayerId(), - context.imageElement, - boxToPolygon(containRectangle), - ); - addEntities([activeImage], true); - }, - [ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => { - setActiveToolActor(new Actor(selectToolStateMachine)); - }, - }, - }, + const activeImage = new ImageEntity( + getActiveLayerId(), + context.imageElement, + boxToPolygon(containRectangle) + ); + addEntities([activeImage], true); + }, + [ImageImportAction.SWITCH_TO_SELECT_TOOL]: () => { + setActiveToolActor(new Actor(selectToolStateMachine)); + }, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts index 892de32a..425b352a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner-tools.ts @@ -50,14 +50,7 @@ export const chamferToolStateMachine = createSequenceTool({ applyCorner( first, second, - chamferLines( - first, - input.pick(2), - second, - input.pick(3), - input.number(0), - input.number(1) - ) + chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1)) ); }, }); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts index 4aec33db..ff95bf3e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/corner.helpers.ts @@ -80,14 +80,8 @@ export function filletLines( } const tangentDistance = radius / Math.tan(angle / 2); - const tangentA = new Point( - corner.x + ua.x * tangentDistance, - corner.y + ua.y * tangentDistance - ); - const tangentB = new Point( - corner.x + ub.x * tangentDistance, - corner.y + ub.y * tangentDistance - ); + const tangentA = new Point(corner.x + ua.x * tangentDistance, corner.y + ua.y * tangentDistance); + const tangentB = new Point(corner.x + ub.x * tangentDistance, corner.y + ub.y * tangentDistance); const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y)); const centerDistance = radius / Math.sin(angle / 2); diff --git a/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts b/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts index be4cc0a1..01475b9d 100644 --- a/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/modify/transform-tools.ts @@ -129,7 +129,11 @@ export const lengthenToolStateMachine = createSequenceTool({ tool: Tool.LENGTHEN, steps: [ { kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' }, - { kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 }, + { + kind: 'number', + instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', + defaultValue: 10, + }, ], commit: (input) => { const entity = input.entity(0); diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts index 8b39afb5..97c15c50 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts @@ -1,4 +1,4 @@ -import type {Entity} from '../entities/Entity'; +import type { Entity } from '../entities/Entity'; /** * Move entities by the difference between the start and end points diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index 39a8de0c..346be909 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -1,49 +1,49 @@ -import type {Point} from '@flatten-js/core'; +import type { Point } from '@flatten-js/core'; import { - addEntities, - deleteEntities, - getActiveLayerId, - getSelectedEntities, - getSelectedEntityIds, - setAngleGuideOriginPoint, - setGhostHelperEntities, - setSelectedEntityIds, - setShouldDrawHelpers, + addEntities, + deleteEntities, + getActiveLayerId, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; -import {assign, createMachine, sendTo} from 'xstate'; -import {selectToolStateMachine} from './select-tool'; -import type {Entity} from '../entities/Entity'; -import {compact} from 'es-toolkit'; -import {moveEntities} from './move-tool.helpers'; -import {LineEntity} from '../entities/LineEntity'; -import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts'; +import { Tool } from '../tools'; +import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { assign, createMachine, sendTo } from 'xstate'; +import { selectToolStateMachine } from './select-tool'; +import type { Entity } from '../entities/Entity'; +import { compact } from 'es-toolkit'; +import { moveEntities } from './move-tool.helpers'; +import { LineEntity } from '../entities/LineEntity'; +import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts'; export interface MoveContext extends ToolContext { - startPoint: Point | null; - originalSelectedEntities: Entity[]; - movedEntities: Entity[]; - lastDrawLocation: Point | null; + startPoint: Point | null; + originalSelectedEntities: Entity[]; + movedEntities: Entity[]; + lastDrawLocation: Point | null; } export enum MoveState { - INIT = 'INIT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', - WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT', - WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT', + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_START_MOVE_POINT = 'WAITING_FOR_START_MOVE_POINT', + WAITING_FOR_END_MOVE_POINT = 'WAITING_FOR_END_MOVE_POINT', } export enum MoveAction { - INIT_MOVE_TOOL = 'INIT_MOVE_TOOL', - ENABLE_HELPERS = 'ENABLE_HELPERS', - RECORD_START_POINT = 'RECORD_START_POINT', - COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE', - DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES', - MOVE_SELECTION = 'MOVE_SELECTION', - DESELECT_ENTITIES = 'DESELECT_ENTITIES', - RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', + INIT_MOVE_TOOL = 'INIT_MOVE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_START_POINT = 'RECORD_START_POINT', + COPY_SELECTION_BEFORE_MOVE = 'COPY_SELECTION_BEFORE_MOVE', + DRAW_TEMP_MOVE_ENTITIES = 'DRAW_TEMP_MOVE_ENTITIES', + MOVE_SELECTION = 'MOVE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } /** @@ -56,243 +56,230 @@ export enum MoveAction { * When the user clicks again, the end point is selected and the entities are moved to the new location */ export const moveToolStateMachine = createMachine( - { - types: {} as { - context: MoveContext; - events: StateEvent; - }, - context: { - startPoint: null, - originalSelectedEntities: [], - movedEntities: [], - lastDrawLocation: null, - type: Tool.MOVE, - }, - initial: MoveState.INIT, - states: { - [MoveState.INIT]: { - description: 'Initializing the move tool', - always: { - actions: MoveAction.INIT_MOVE_TOOL, - target: MoveState.CHECK_SELECTION, - }, - }, - [MoveState.CHECK_SELECTION]: { - description: 'Check if there is something selected', - always: [ - { - guard: () => { - return getSelectedEntityIds().length > 0; - }, - target: MoveState.WAITING_FOR_START_MOVE_POINT, - }, - { - guard: () => { - return getSelectedEntityIds().length === 0; - }, - target: MoveState.WAITING_FOR_SELECTION, - }, - ], - }, - [MoveState.WAITING_FOR_SELECTION]: { - description: 'Select what you want to move', - meta: { - instructions: 'Select what you want to move, then ENTER', - }, - invoke: { - id: 'selectToolInsideTheMoveTool', - src: selectToolStateMachine, - onDone: { - actions: assign(() => { - return { - startPoint: null, - }; - }), - target: MoveState.CHECK_SELECTION, - }, - }, - on: { - MOUSE_CLICK: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { - return event; - }), - }, - ESC: { - actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL], - }, - ENTER: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { - return event; - }), - }, - DRAW: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { - return event; - }), - }, - }, - }, - [MoveState.WAITING_FOR_START_MOVE_POINT]: { - description: 'Select the start of the move line', - meta: { - instructions: 'Select the start of the move line', - }, - always: { - actions: MoveAction.ENABLE_HELPERS, - }, - on: { - MOUSE_CLICK: { - actions: [ - MoveAction.RECORD_START_POINT, - MoveAction.COPY_SELECTION_BEFORE_MOVE, - ], - target: MoveState.WAITING_FOR_END_MOVE_POINT, - }, - ESC: { - actions: MoveAction.DESELECT_ENTITIES, - target: MoveState.INIT, - }, - }, - }, - [MoveState.WAITING_FOR_END_MOVE_POINT]: { - description: 'Select the end of the move line', - meta: { - instructions: 'Select the end of the move line', - }, - on: { - DRAW: { - actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES], - }, - MOUSE_CLICK: { - actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], - target: MoveState.WAITING_FOR_SELECTION, - }, - ESC: { - actions: MoveAction.RESTORE_ORIGINAL_ENTITIES, - target: MoveState.INIT, - }, - }, - }, - }, - }, - { - actions: { - [MoveAction.INIT_MOVE_TOOL]: assign(() => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - setAngleGuideOriginPoint(null); - return { - startPoint: null, - originalSelectedEntities: [], - movedEntities: [], - lastDrawLocation: null, - }; - }), - [MoveAction.ENABLE_HELPERS]: () => { - setShouldDrawHelpers(true); - }, - [MoveAction.RECORD_START_POINT]: assign(({ event }) => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - startPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }), - [MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => { - const selectedEntities = getSelectedEntities(); + { + types: {} as { + context: MoveContext; + events: StateEvent; + }, + context: { + startPoint: null, + originalSelectedEntities: [], + movedEntities: [], + lastDrawLocation: null, + type: Tool.MOVE, + }, + initial: MoveState.INIT, + states: { + [MoveState.INIT]: { + description: 'Initializing the move tool', + always: { + actions: MoveAction.INIT_MOVE_TOOL, + target: MoveState.CHECK_SELECTION, + }, + }, + [MoveState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: MoveState.WAITING_FOR_START_MOVE_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: MoveState.WAITING_FOR_SELECTION, + }, + ], + }, + [MoveState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to move', + meta: { + instructions: 'Select what you want to move, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheMoveTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + startPoint: null, + }; + }), + target: MoveState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [MoveAction.DESELECT_ENTITIES, MoveAction.INIT_MOVE_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheMoveTool', ({ event }) => { + return event; + }), + }, + }, + }, + [MoveState.WAITING_FOR_START_MOVE_POINT]: { + description: 'Select the start of the move line', + meta: { + instructions: 'Select the start of the move line', + }, + always: { + actions: MoveAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE], + target: MoveState.WAITING_FOR_END_MOVE_POINT, + }, + ESC: { + actions: MoveAction.DESELECT_ENTITIES, + target: MoveState.INIT, + }, + }, + }, + [MoveState.WAITING_FOR_END_MOVE_POINT]: { + description: 'Select the end of the move line', + meta: { + instructions: 'Select the end of the move line', + }, + on: { + DRAW: { + actions: [MoveAction.DRAW_TEMP_MOVE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: MoveAction.RESTORE_ORIGINAL_ENTITIES, + target: MoveState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [MoveAction.INIT_MOVE_TOOL]: assign(() => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + return { + startPoint: null, + originalSelectedEntities: [], + movedEntities: [], + lastDrawLocation: null, + }; + }), + [MoveAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [MoveAction.RECORD_START_POINT]: assign(({ event }) => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + startPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); - // Move the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides - setGhostHelperEntities(selectedEntities); - // Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides - deleteEntities(selectedEntities, false); + // Move the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + // Remove the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides + deleteEntities(selectedEntities, false); - // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being moved and the original entities also are used for snap points / angle guides + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being moved and the original entities also are used for snap points / angle guides - setSelectedEntityIds([]); - return { - startPoint: context.startPoint, - // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action - originalSelectedEntities: compact( - selectedEntities.map(entity => entity.clone()), - ), - movedEntities: selectedEntities, - }; - }), - [MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error( - '[MOVE] Calling draw temp move line without a start point', - ); - } + setSelectedEntityIds([]); + return { + startPoint: context.startPoint, + // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action + originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + movedEntities: selectedEntities, + }; + }), + [MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error('[MOVE] Calling draw temp move line without a start point'); + } - const endPointTemp = ( - event as DrawEvent - ).drawController.getWorldMouseLocation(); + const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); - // Move the entities to the new location - // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; - const movedEntities = context.originalSelectedEntities.map(entity => - entity.clone(), - ); - moveEntities( - movedEntities, - endPointTemp.x - context.startPoint.x, - endPointTemp.y - context.startPoint.y, - ); + // Move the entities to the new location + // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; + const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + moveEntities( + movedEntities, + endPointTemp.x - context.startPoint.x, + endPointTemp.y - context.startPoint.y + ); - // // Draw a dashed line between the start move point and the current mouse location - const activeMoveLine = new LineEntity( - getActiveLayerId(), - context.startPoint as Point, - endPointTemp, - ); - activeMoveLine.lineColor = GUIDE_LINE_COLOR; - activeMoveLine.lineWidth = GUIDE_LINE_WIDTH; - activeMoveLine.lineDash = GUIDE_LINE_STYLE; - setGhostHelperEntities([activeMoveLine, ...movedEntities]); - }, - [MoveAction.MOVE_SELECTION]: ({ context, event }) => { - if (!context.startPoint) { - throw new Error( - '[MOVE] Calling move selection without a start point', - ); - } + // // Draw a dashed line between the start move point and the current mouse location + const activeMoveLine = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + endPointTemp + ); + activeMoveLine.lineColor = GUIDE_LINE_COLOR; + activeMoveLine.lineWidth = GUIDE_LINE_WIDTH; + activeMoveLine.lineDash = GUIDE_LINE_STYLE; + setGhostHelperEntities([activeMoveLine, ...movedEntities]); + }, + [MoveAction.MOVE_SELECTION]: ({ context, event }) => { + if (!context.startPoint) { + throw new Error('[MOVE] Calling move selection without a start point'); + } - // Move the entities one final time - const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; - moveEntities( - context.originalSelectedEntities, - currentEndPoint.x - context.startPoint.x, - currentEndPoint.y - context.startPoint.y, - ); + // Move the entities one final time + const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; + moveEntities( + context.originalSelectedEntities, + currentEndPoint.x - context.startPoint.x, + currentEndPoint.y - context.startPoint.y + ); - // Switch the moved entities back from the ghost helper entities to the real entities - addEntities(context.originalSelectedEntities, true); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - }, - [MoveAction.DESELECT_ENTITIES]: assign(() => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - [MoveAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { - addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - ...selectToolStateMachine.implementations.actions, - }, - }, + // Switch the moved entities back from the ghost helper entities to the real entities + addEntities(context.originalSelectedEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [MoveAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [MoveAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state of the undo stack + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts index 2e7447d3..d00c3874 100644 --- a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts @@ -1,6 +1,6 @@ -import {toast} from 'react-toastify'; -import {assign, createMachine, sendTo} from 'xstate'; -import {PolyLineEntity} from '../entities/PolyLineEntity.ts'; +import { toast } from 'react-toastify'; +import { assign, createMachine, sendTo } from 'xstate'; +import { PolyLineEntity } from '../entities/PolyLineEntity.ts'; import { getActiveLayerId, getNotSelectedEntities, @@ -12,9 +12,9 @@ import { setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import {selectToolStateMachine} from './select-tool'; -import type {StateEvent, ToolContext} from './tool.types'; +import { Tool } from '../tools'; +import { selectToolStateMachine } from './select-tool'; +import type { StateEvent, ToolContext } from './tool.types'; export interface PeditContext extends ToolContext {} diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts index 8687f39b..d29b34ba 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.helpers.ts @@ -1,5 +1,5 @@ -import {Line, type Point} from '@flatten-js/core'; -import type {Entity} from '../entities/Entity'; +import { Line, type Point } from '@flatten-js/core'; +import type { Entity } from '../entities/Entity'; /** * Rotate entities round a base point by a certain angle diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index b89db405..0d792a54 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -1,47 +1,47 @@ -import type {Point} from '@flatten-js/core'; +import type { Point } from '@flatten-js/core'; import { - addEntities, - deleteEntities, - getSelectedEntities, - getSelectedEntityIds, - setAngleGuideOriginPoint, - setGhostHelperEntities, - setSelectedEntityIds, - setShouldDrawHelpers, + addEntities, + deleteEntities, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; -import {assign, createMachine, sendTo} from 'xstate'; -import {selectToolStateMachine} from './select-tool'; -import type {Entity} from '../entities/Entity'; -import {compact} from 'es-toolkit'; -import {rotateEntities} from './rotate-tool.helpers'; +import { Tool } from '../tools'; +import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { assign, createMachine, sendTo } from 'xstate'; +import { selectToolStateMachine } from './select-tool'; +import type { Entity } from '../entities/Entity'; +import { compact } from 'es-toolkit'; +import { rotateEntities } from './rotate-tool.helpers'; export interface RotateContext extends ToolContext { - rotationOrigin: Point | null; - angleStartPoint: Point | null; - originalSelectedEntities: Entity[]; + rotationOrigin: Point | null; + angleStartPoint: Point | null; + originalSelectedEntities: Entity[]; } export enum RotateState { - INIT = 'INIT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', - WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN', - WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT', - WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT', + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_ROTATION_ORIGIN = 'WAITING_FOR_ROTATION_ORIGIN', + WAITING_FOR_ANGLE_START_POINT = 'WAITING_FOR_ANGLE_START_POINT', + WAITING_FOR_ANGLE_END_POINT = 'WAITING_FOR_ANGLE_END_POINT', } export enum RotateAction { - INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL', - ENABLE_HELPERS = 'ENABLE_HELPERS', - RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN', - RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT', - COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE', - DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES', - ROTATE_SELECTION = 'ROTATE_SELECTION', - DESELECT_ENTITIES = 'DESELECT_ENTITIES', - RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', + INIT_ROTATE_TOOL = 'INIT_ROTATE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_ROTATION_ORIGIN = 'RECORD_ROTATION_ORIGIN', + RECORD_ROTATION_ANGLE_START_POINT = 'RECORD_ROTATION_ANGLE_START_POINT', + COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE', + DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES', + ROTATE_SELECTION = 'ROTATE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } /** @@ -55,271 +55,251 @@ export enum RotateAction { * When the user clicks again, the angle is locked in and the entities are rotated around the rotation origin */ export const rotateToolStateMachine = createMachine( - { - types: {} as { - context: RotateContext; - events: StateEvent; - }, - context: { - rotationOrigin: null, - angleStartPoint: null, - originalSelectedEntities: [], - type: Tool.ROTATE, - }, - initial: RotateState.INIT, - states: { - [RotateState.INIT]: { - description: 'Initializing the rotate tool', - always: { - actions: RotateAction.INIT_ROTATE_TOOL, - target: RotateState.CHECK_SELECTION, - }, - }, - [RotateState.CHECK_SELECTION]: { - description: 'Check if there is something selected', - always: [ - { - guard: () => { - return getSelectedEntityIds().length > 0; - }, - target: RotateState.WAITING_FOR_ROTATION_ORIGIN, - }, - { - guard: () => { - return getSelectedEntityIds().length === 0; - }, - target: RotateState.WAITING_FOR_SELECTION, - }, - ], - }, - [RotateState.WAITING_FOR_SELECTION]: { - description: 'Select what you want to rotate', - meta: { - instructions: 'Select what you want to rotate, then ENTER', - }, - invoke: { - id: 'selectToolInsideTheRotateTool', - src: selectToolStateMachine, - onDone: { - actions: assign(({ context }) => { - return { - ...context, - }; - }), - target: RotateState.CHECK_SELECTION, - }, - }, - on: { - MOUSE_CLICK: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { - return event; - }), - }, - ESC: { - actions: [ - RotateAction.DESELECT_ENTITIES, - RotateAction.INIT_ROTATE_TOOL, - ], - }, - ENTER: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { - return event; - }), - }, - DRAW: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { - return event; - }), - }, - }, - }, - [RotateState.WAITING_FOR_ROTATION_ORIGIN]: { - description: 'Select the origin of the rotate operation', - meta: { - instructions: 'Select the origin of the rotate operation', - }, - always: { - actions: RotateAction.ENABLE_HELPERS, - }, - on: { - MOUSE_CLICK: { - actions: [RotateAction.RECORD_ROTATION_ORIGIN], - target: RotateState.WAITING_FOR_ANGLE_START_POINT, - }, - ESC: { - actions: RotateAction.DESELECT_ENTITIES, - target: RotateState.INIT, - }, - }, - }, - [RotateState.WAITING_FOR_ANGLE_START_POINT]: { - description: 'Select the end of the base rotate line', - meta: { - instructions: 'Select the end of the base rotate line', - }, - on: { - MOUSE_CLICK: { - actions: [ - RotateAction.RECORD_ROTATION_ANGLE_START_POINT, - RotateAction.COPY_SELECTION_BEFORE_ROTATE, - ], - target: RotateState.WAITING_FOR_ANGLE_END_POINT, - }, - ESC: { - actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, - target: RotateState.INIT, - }, - }, - }, - [RotateState.WAITING_FOR_ANGLE_END_POINT]: { - description: 'Select the end of the rotate line', - meta: { - instructions: 'Select the end of the rotate line', - }, - on: { - DRAW: { - actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES], - }, - MOUSE_CLICK: { - actions: [ - RotateAction.ROTATE_SELECTION, - RotateAction.DESELECT_ENTITIES, - ], - target: RotateState.WAITING_FOR_SELECTION, - }, - ESC: { - actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, - target: RotateState.INIT, - }, - }, - }, - }, - }, - { - actions: { - [RotateAction.INIT_ROTATE_TOOL]: () => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - setAngleGuideOriginPoint(null); - }, - [RotateAction.ENABLE_HELPERS]: () => { - setShouldDrawHelpers(true); - }, - [RotateAction.RECORD_ROTATION_ORIGIN]: assign( - ({ context, event }): RotateContext => { - setAngleGuideOriginPoint( - (event as MouseClickEvent).worldMouseLocation, - ); - return { - ...context, - rotationOrigin: (event as MouseClickEvent).worldMouseLocation, - }; - }, - ), - [RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign( - ({ context, event }): RotateContext => { - return { - ...context, - angleStartPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }, - ), - [RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign( - ({ context }): RotateContext => { - const selectedEntities = getSelectedEntities(); + { + types: {} as { + context: RotateContext; + events: StateEvent; + }, + context: { + rotationOrigin: null, + angleStartPoint: null, + originalSelectedEntities: [], + type: Tool.ROTATE, + }, + initial: RotateState.INIT, + states: { + [RotateState.INIT]: { + description: 'Initializing the rotate tool', + always: { + actions: RotateAction.INIT_ROTATE_TOOL, + target: RotateState.CHECK_SELECTION, + }, + }, + [RotateState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: RotateState.WAITING_FOR_ROTATION_ORIGIN, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: RotateState.WAITING_FOR_SELECTION, + }, + ], + }, + [RotateState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to rotate', + meta: { + instructions: 'Select what you want to rotate, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheRotateTool', + src: selectToolStateMachine, + onDone: { + actions: assign(({ context }) => { + return { + ...context, + }; + }), + target: RotateState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [RotateAction.DESELECT_ENTITIES, RotateAction.INIT_ROTATE_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheRotateTool', ({ event }) => { + return event; + }), + }, + }, + }, + [RotateState.WAITING_FOR_ROTATION_ORIGIN]: { + description: 'Select the origin of the rotate operation', + meta: { + instructions: 'Select the origin of the rotate operation', + }, + always: { + actions: RotateAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [RotateAction.RECORD_ROTATION_ORIGIN], + target: RotateState.WAITING_FOR_ANGLE_START_POINT, + }, + ESC: { + actions: RotateAction.DESELECT_ENTITIES, + target: RotateState.INIT, + }, + }, + }, + [RotateState.WAITING_FOR_ANGLE_START_POINT]: { + description: 'Select the end of the base rotate line', + meta: { + instructions: 'Select the end of the base rotate line', + }, + on: { + MOUSE_CLICK: { + actions: [ + RotateAction.RECORD_ROTATION_ANGLE_START_POINT, + RotateAction.COPY_SELECTION_BEFORE_ROTATE, + ], + target: RotateState.WAITING_FOR_ANGLE_END_POINT, + }, + ESC: { + actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, + target: RotateState.INIT, + }, + }, + }, + [RotateState.WAITING_FOR_ANGLE_END_POINT]: { + description: 'Select the end of the rotate line', + meta: { + instructions: 'Select the end of the rotate line', + }, + on: { + DRAW: { + actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [RotateAction.ROTATE_SELECTION, RotateAction.DESELECT_ENTITIES], + target: RotateState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, + target: RotateState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [RotateAction.INIT_ROTATE_TOOL]: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + [RotateAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + ...context, + rotationOrigin: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign( + ({ context, event }): RotateContext => { + return { + ...context, + angleStartPoint: (event as MouseClickEvent).worldMouseLocation, + }; + } + ), + [RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(({ context }): RotateContext => { + const selectedEntities = getSelectedEntities(); - // Rotate the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides - setGhostHelperEntities(selectedEntities); - // Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides - deleteEntities(selectedEntities, false); + // Rotate the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + // Re-rotate the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides + deleteEntities(selectedEntities, false); - // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being rotated and the original entities also are used for snap points / angle guides + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being rotated and the original entities also are used for snap points / angle guides - setSelectedEntityIds([]); - return { - ...context, - // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action - originalSelectedEntities: compact( - selectedEntities.map(entity => entity.clone()), - ), - }; - }, - ), - [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { - if (!context.rotationOrigin || !context.angleStartPoint) { - throw new Error( - '[ROTATE] Calling draw temp rotate entities without a base start point or base end point', - ); - } + setSelectedEntityIds([]); + return { + ...context, + // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action + originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + }; + }), + [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { + if (!context.rotationOrigin || !context.angleStartPoint) { + throw new Error( + '[ROTATE] Calling draw temp rotate entities without a base start point or base end point' + ); + } - const angleEndpoint = ( - event as DrawEvent - ).drawController.getWorldMouseLocation(); + const angleEndpoint = (event as DrawEvent).drawController.getWorldMouseLocation(); - // Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating - const rotatedEntities = compact( - context.originalSelectedEntities.map(entity => entity.clone()), - ); - rotateEntities( - rotatedEntities, - context.rotationOrigin, - context.angleStartPoint, - angleEndpoint, - ); + // Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating + const rotatedEntities = compact( + context.originalSelectedEntities.map((entity) => entity.clone()) + ); + rotateEntities( + rotatedEntities, + context.rotationOrigin, + context.angleStartPoint, + angleEndpoint + ); - setGhostHelperEntities(rotatedEntities); - }, - [RotateAction.ROTATE_SELECTION]: ({ context, event }) => { - if (!context.rotationOrigin || !context.angleStartPoint) { - throw new Error( - '[ROTATE] Calling rotate selection without some rotate vector endpoints', - ); - } - const angleEndpoint = (event as MouseClickEvent).worldMouseLocation; + setGhostHelperEntities(rotatedEntities); + }, + [RotateAction.ROTATE_SELECTION]: ({ context, event }) => { + if (!context.rotationOrigin || !context.angleStartPoint) { + throw new Error('[ROTATE] Calling rotate selection without some rotate vector endpoints'); + } + const angleEndpoint = (event as MouseClickEvent).worldMouseLocation; - // Rotate the entities one final time - const rotatedEntities = compact( - context.originalSelectedEntities.map(entity => entity.clone()), - ); - rotateEntities( - rotatedEntities, - context.rotationOrigin, - context.angleStartPoint, - angleEndpoint, - ); + // Rotate the entities one final time + const rotatedEntities = compact( + context.originalSelectedEntities.map((entity) => entity.clone()) + ); + rotateEntities( + rotatedEntities, + context.rotationOrigin, + context.angleStartPoint, + angleEndpoint + ); - // Switch the rotated entities back from the ghost helper entities to the real entities - addEntities(rotatedEntities, true); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - }, - [RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - ...context, - rotationOrigin: null, - angleStartPoint: null, - originalSelectedEntities: [], - }; - }), - [RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign( - ({ context }): RotateContext => { - addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - ...context, - rotationOrigin: null, - angleStartPoint: null, - originalSelectedEntities: [], - }; - }, - ), - ...selectToolStateMachine.implementations.actions, - }, - }, + // Switch the rotated entities back from the ghost helper entities to the real entities + addEntities(rotatedEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + ...context, + rotationOrigin: null, + angleStartPoint: null, + originalSelectedEntities: [], + }; + }), + [RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }): RotateContext => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + ...context, + rotationOrigin: null, + angleStartPoint: null, + originalSelectedEntities: [], + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts index fda881b3..fb8284d7 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.helpers.ts @@ -1,6 +1,6 @@ -import type {Point} from '@flatten-js/core'; -import type {Entity} from '../entities/Entity'; -import {pointDistance} from '../helpers/distance-between-points'; +import type { Point } from '@flatten-js/core'; +import type { Entity } from '../entities/Entity'; +import { pointDistance } from '../helpers/distance-between-points'; /** * Scale entities by base vector to destination scale vector diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 3d81e395..99b5ed11 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -1,48 +1,48 @@ -import type {Point} from '@flatten-js/core'; +import type { Point } from '@flatten-js/core'; import { - addEntities, - deleteEntities, - getSelectedEntities, - getSelectedEntityIds, - setAngleGuideOriginPoint, - setGhostHelperEntities, - setSelectedEntityIds, - setShouldDrawHelpers, + addEntities, + deleteEntities, + getSelectedEntities, + getSelectedEntityIds, + setAngleGuideOriginPoint, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, } from '../state'; -import {Tool} from '../tools'; -import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; -import {assign, createMachine, sendTo} from 'xstate'; -import {selectToolStateMachine} from './select-tool'; -import type {Entity} from '../entities/Entity'; -import {compact} from 'es-toolkit'; -import {scaleEntities} from './scale-tool.helpers'; +import { Tool } from '../tools'; +import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { assign, createMachine, sendTo } from 'xstate'; +import { selectToolStateMachine } from './select-tool'; +import type { Entity } from '../entities/Entity'; +import { compact } from 'es-toolkit'; +import { scaleEntities } from './scale-tool.helpers'; export interface ScaleContext extends ToolContext { - baseVectorStartPoint: Point | null; - baseVectorEndPoint: Point | null; - scaleVectorEndPoint: Point | null; - originalSelectedEntities: Entity[]; + baseVectorStartPoint: Point | null; + baseVectorEndPoint: Point | null; + scaleVectorEndPoint: Point | null; + originalSelectedEntities: Entity[]; } export enum ScaleState { - INIT = 'INIT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', - WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT', - WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT', - WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT', + INIT = 'INIT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SELECTION = 'WAITING_FOR_SELECTION', + WAITING_FOR_BASE_VECTOR_START_POINT = 'WAITING_FOR_BASE_VECTOR_START_POINT', + WAITING_FOR_BASE_VECTOR_END_POINT = 'WAITING_FOR_BASE_VECTOR_END_POINT', + WAITING_FOR_SCALE_VECTOR_END_POINT = 'WAITING_FOR_SCALE_VECTOR_END_POINT', } export enum ScaleAction { - INIT_SCALE_TOOL = 'INIT_SCALE_TOOL', - ENABLE_HELPERS = 'ENABLE_HELPERS', - RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT', - RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT', - COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', - DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', - SCALE_SELECTION = 'SCALE_SELECTION', - DESELECT_ENTITIES = 'DESELECT_ENTITIES', - RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', + INIT_SCALE_TOOL = 'INIT_SCALE_TOOL', + ENABLE_HELPERS = 'ENABLE_HELPERS', + RECORD_BASE_VECTOR_START_POINT = 'RECORD_BASE_VECTOR_START_POINT', + RECORD_BASE_VECTOR_END_POINT = 'RECORD_BASE_VECTOR_END_POINT', + COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', + DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', + SCALE_SELECTION = 'SCALE_SELECTION', + DESELECT_ENTITIES = 'DESELECT_ENTITIES', + RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } /** @@ -56,271 +56,252 @@ export enum ScaleAction { * When the user clicks again, the scale vector end point is selected and the entities are scaled according to the scale vector */ export const scaleToolStateMachine = createMachine( - { - types: {} as { - context: ScaleContext; - events: StateEvent; - }, - context: { - baseVectorStartPoint: null, - baseVectorEndPoint: null, - scaleVectorEndPoint: null, - originalSelectedEntities: [], - type: Tool.SCALE, - }, - initial: ScaleState.INIT, - states: { - [ScaleState.INIT]: { - description: 'Initializing the scale tool', - always: { - actions: ScaleAction.INIT_SCALE_TOOL, - target: ScaleState.CHECK_SELECTION, - }, - }, - [ScaleState.CHECK_SELECTION]: { - description: 'Check if there is something selected', - always: [ - { - guard: () => { - return getSelectedEntityIds().length > 0; - }, - target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT, - }, - { - guard: () => { - return getSelectedEntityIds().length === 0; - }, - target: ScaleState.WAITING_FOR_SELECTION, - }, - ], - }, - [ScaleState.WAITING_FOR_SELECTION]: { - description: 'Select what you want to scale', - meta: { - instructions: 'Select what you want to scale, then ENTER', - }, - invoke: { - id: 'selectToolInsideTheScaleTool', - src: selectToolStateMachine, - onDone: { - actions: assign(() => { - return { - baseVectorStartPoint: null, - baseVectorEndPoint: null, - scaleVectorEndPoint: null, - originalSelectedEntities: [], - }; - }), - target: ScaleState.CHECK_SELECTION, - }, - }, - on: { - MOUSE_CLICK: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { - return event; - }), - }, - ESC: { - actions: [ - ScaleAction.DESELECT_ENTITIES, - ScaleAction.INIT_SCALE_TOOL, - ], - }, - ENTER: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { - return event; - }), - }, - DRAW: { - // Forward the event to the select tool - actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { - return event; - }), - }, - }, - }, - [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { - description: 'Select the origin of the scale operation', - meta: { - instructions: 'Select the origin of the scale operation', - }, - always: { - actions: ScaleAction.ENABLE_HELPERS, - }, - on: { - MOUSE_CLICK: { - actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT], - target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT, - }, - ESC: { - actions: ScaleAction.DESELECT_ENTITIES, - target: ScaleState.INIT, - }, - }, - }, - [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { - description: 'Select the end of the base scale line', - meta: { - instructions: 'Select the end of the base scale line', - }, - on: { - MOUSE_CLICK: { - actions: [ - ScaleAction.RECORD_BASE_VECTOR_END_POINT, - ScaleAction.COPY_SELECTION_BEFORE_SCALE, - ], - target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT, - }, - ESC: { - actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, - target: ScaleState.INIT, - }, - }, - }, - [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { - description: 'Select the end of the scale line', - meta: { - instructions: 'Select the end of the scale line', - }, - on: { - DRAW: { - actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES], - }, - MOUSE_CLICK: { - actions: [ - ScaleAction.SCALE_SELECTION, - ScaleAction.DESELECT_ENTITIES, - ], - target: ScaleState.WAITING_FOR_SELECTION, - }, - ESC: { - actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, - target: ScaleState.INIT, - }, - }, - }, - }, - }, - { - actions: { - [ScaleAction.INIT_SCALE_TOOL]: () => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - setAngleGuideOriginPoint(null); - }, - [ScaleAction.ENABLE_HELPERS]: () => { - setShouldDrawHelpers(true); - }, - [ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign( - ({ context, event }) => { - setAngleGuideOriginPoint( - (event as MouseClickEvent).worldMouseLocation, - ); - return { - ...context, - baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }, - ), - [ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign( - ({ context, event }) => { - return { - ...context, - baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation, - }; - }, - ), - [ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => { - const selectedEntities = getSelectedEntities(); + { + types: {} as { + context: ScaleContext; + events: StateEvent; + }, + context: { + baseVectorStartPoint: null, + baseVectorEndPoint: null, + scaleVectorEndPoint: null, + originalSelectedEntities: [], + type: Tool.SCALE, + }, + initial: ScaleState.INIT, + states: { + [ScaleState.INIT]: { + description: 'Initializing the scale tool', + always: { + actions: ScaleAction.INIT_SCALE_TOOL, + target: ScaleState.CHECK_SELECTION, + }, + }, + [ScaleState.CHECK_SELECTION]: { + description: 'Check if there is something selected', + always: [ + { + guard: () => { + return getSelectedEntityIds().length > 0; + }, + target: ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT, + }, + { + guard: () => { + return getSelectedEntityIds().length === 0; + }, + target: ScaleState.WAITING_FOR_SELECTION, + }, + ], + }, + [ScaleState.WAITING_FOR_SELECTION]: { + description: 'Select what you want to scale', + meta: { + instructions: 'Select what you want to scale, then ENTER', + }, + invoke: { + id: 'selectToolInsideTheScaleTool', + src: selectToolStateMachine, + onDone: { + actions: assign(() => { + return { + baseVectorStartPoint: null, + baseVectorEndPoint: null, + scaleVectorEndPoint: null, + originalSelectedEntities: [], + }; + }), + target: ScaleState.CHECK_SELECTION, + }, + }, + on: { + MOUSE_CLICK: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + ESC: { + actions: [ScaleAction.DESELECT_ENTITIES, ScaleAction.INIT_SCALE_TOOL], + }, + ENTER: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + DRAW: { + // Forward the event to the select tool + actions: sendTo('selectToolInsideTheScaleTool', ({ event }) => { + return event; + }), + }, + }, + }, + [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { + description: 'Select the origin of the scale operation', + meta: { + instructions: 'Select the origin of the scale operation', + }, + always: { + actions: ScaleAction.ENABLE_HELPERS, + }, + on: { + MOUSE_CLICK: { + actions: [ScaleAction.RECORD_BASE_VECTOR_START_POINT], + target: ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT, + }, + ESC: { + actions: ScaleAction.DESELECT_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { + description: 'Select the end of the base scale line', + meta: { + instructions: 'Select the end of the base scale line', + }, + on: { + MOUSE_CLICK: { + actions: [ + ScaleAction.RECORD_BASE_VECTOR_END_POINT, + ScaleAction.COPY_SELECTION_BEFORE_SCALE, + ], + target: ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT, + }, + ESC: { + actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { + description: 'Select the end of the scale line', + meta: { + instructions: 'Select the end of the scale line', + }, + on: { + DRAW: { + actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES], + }, + MOUSE_CLICK: { + actions: [ScaleAction.SCALE_SELECTION, ScaleAction.DESELECT_ENTITIES], + target: ScaleState.WAITING_FOR_SELECTION, + }, + ESC: { + actions: ScaleAction.RESTORE_ORIGINAL_ENTITIES, + target: ScaleState.INIT, + }, + }, + }, + }, + }, + { + actions: { + [ScaleAction.INIT_SCALE_TOOL]: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + setAngleGuideOriginPoint(null); + }, + [ScaleAction.ENABLE_HELPERS]: () => { + setShouldDrawHelpers(true); + }, + [ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(({ context, event }) => { + setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); + return { + ...context, + baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(({ context, event }) => { + return { + ...context, + baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation, + }; + }), + [ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => { + const selectedEntities = getSelectedEntities(); - // Scale the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides - setGhostHelperEntities(selectedEntities); - // Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides - deleteEntities(selectedEntities, false); + // Scale the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides + setGhostHelperEntities(selectedEntities); + // Rescale the selected entities from the regular entity list, so they do not get used for determining snap points / angle guides + deleteEntities(selectedEntities, false); - // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being scaled and the original entities also are used for snap points / angle guides + // TODO keep a copy of the original entities in the entities list, but set their line color to grey, so the user can see where the entities were before being scaled and the original entities also are used for snap points / angle guides - setSelectedEntityIds([]); - return { - ...context, - // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action - originalSelectedEntities: compact( - selectedEntities.map(entity => entity.clone()), - ), - }; - }), - [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { - if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { - throw new Error( - '[SCALE] Calling draw temp scale entities without a base start point or base end point', - ); - } + setSelectedEntityIds([]); + return { + ...context, + // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action + originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + }; + }), + [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { + if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { + throw new Error( + '[SCALE] Calling draw temp scale entities without a base start point or base end point' + ); + } - const scaleVectorEndPointTemp = ( - event as DrawEvent - ).drawController.getWorldMouseLocation(); + const scaleVectorEndPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); - // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling - const scaledEntities = compact( - context.originalSelectedEntities.map(entity => entity.clone()), - ); - scaleEntities( - scaledEntities, - context.baseVectorStartPoint, - context.baseVectorEndPoint, - scaleVectorEndPointTemp, - ); + // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling + const scaledEntities = compact( + context.originalSelectedEntities.map((entity) => entity.clone()) + ); + scaleEntities( + scaledEntities, + context.baseVectorStartPoint, + context.baseVectorEndPoint, + scaleVectorEndPointTemp + ); - setGhostHelperEntities(scaledEntities); - }, - [ScaleAction.SCALE_SELECTION]: ({ context, event }) => { - if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { - throw new Error( - '[SCALE] Calling scale selection without some scale vector endpoints', - ); - } - const scaleVectorEndPoint = (event as MouseClickEvent) - .worldMouseLocation; + setGhostHelperEntities(scaledEntities); + }, + [ScaleAction.SCALE_SELECTION]: ({ context, event }) => { + if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) { + throw new Error('[SCALE] Calling scale selection without some scale vector endpoints'); + } + const scaleVectorEndPoint = (event as MouseClickEvent).worldMouseLocation; - // Scale the entities one final time - const scaledEntities = compact( - context.originalSelectedEntities.map(entity => entity.clone()), - ); - scaleEntities( - scaledEntities, - context.baseVectorStartPoint, - context.baseVectorEndPoint, - scaleVectorEndPoint, - ); + // Scale the entities one final time + const scaledEntities = compact( + context.originalSelectedEntities.map((entity) => entity.clone()) + ); + scaleEntities( + scaledEntities, + context.baseVectorStartPoint, + context.baseVectorEndPoint, + scaleVectorEndPoint + ); - // Switch the scaled entities back from the ghost helper entities to the real entities - addEntities(scaledEntities, true); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - }, - [ScaleAction.DESELECT_ENTITIES]: assign(() => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - [ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { - addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack - setGhostHelperEntities([]); - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - originalSelectedEntities: [], - lastDrawLocation: null, - }; - }), - ...selectToolStateMachine.implementations.actions, - }, - }, + // Switch the scaled entities back from the ghost helper entities to the real entities + addEntities(scaledEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, + [ScaleAction.DESELECT_ENTITIES]: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + [ScaleAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }) => { + addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack + setGhostHelperEntities([]); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + originalSelectedEntities: [], + lastDrawLocation: null, + }; + }), + ...selectToolStateMachine.implementations.actions, + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts index 5b99270f..55a33926 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts @@ -1,180 +1,181 @@ -import type {Point} from '@flatten-js/core'; -import {getNotSelectedEntities, setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers,} from '../state'; -import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types'; -import {Tool} from '../tools'; -import {assign, createMachine} from 'xstate'; -import {drawTempSelectionRectangle, handleFirstSelectionPoint, selectEntitiesInsideRectangle,} from './select-tool.helpers'; +import type { Point } from '@flatten-js/core'; +import { + getNotSelectedEntities, + setEntities, + setGhostHelperEntities, + setSelectedEntityIds, + setShouldDrawHelpers, +} from '../state'; +import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import { Tool } from '../tools'; +import { assign, createMachine } from 'xstate'; +import { + drawTempSelectionRectangle, + handleFirstSelectionPoint, + selectEntitiesInsideRectangle, +} from './select-tool.helpers'; export interface SelectContext extends ToolContext { - startPoint: Point | null; + startPoint: Point | null; } export enum SelectState { - INIT = 'INIT', - WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT', - CHECK_SELECTION = 'CHECK_SELECTION', - WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT', - SELECTION_COMPLETED = 'SELECTION_COMPLETED', + INIT = 'INIT', + WAITING_FOR_FIRST_SELECT_POINT = 'WAITING_FOR_FIRST_SELECT_POINT', + CHECK_SELECTION = 'CHECK_SELECTION', + WAITING_FOR_SECOND_SELECT_POINT = 'WAITING_FOR_SECOND_SELECT_POINT', + SELECTION_COMPLETED = 'SELECTION_COMPLETED', } export enum SelectAction { - INIT_SELECT_TOOL = 'INIT_SELECT_TOOL', - HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT', - SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE', - DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE', - DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES', + INIT_SELECT_TOOL = 'INIT_SELECT_TOOL', + HANDLE_FIRST_SELECT_POINT = 'HANDLE_FIRST_SELECT_POINT', + SELECT_ENTITIES_INSIDE_RECTANGLE = 'SELECT_ENTITIES_INSIDE_RECTANGLE', + DRAW_TEMP_SELECTION_RECTANGLE = 'DRAW_TEMP_SELECTION_RECTANGLE', + DELETE_SELECTED_ENTITIES = 'DELETE_SELECTED_ENTITIES', } export const selectToolStateMachine = createMachine( - { - types: {} as { - context: SelectContext; - events: StateEvent; - }, - context: { - startPoint: null, - type: Tool.SELECT, - }, - initial: SelectState.INIT, - states: { - [SelectState.INIT]: { - description: 'Initializing the select tool', - always: { - actions: SelectAction.INIT_SELECT_TOOL, - target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, - }, - }, - [SelectState.WAITING_FOR_FIRST_SELECT_POINT]: { - description: - 'Select a line or select the first point of a selection rectangle', - meta: { - instructions: 'Select a line or start drawing a selection rectangle', - }, - on: { - MOUSE_CLICK: { - actions: SelectAction.HANDLE_FIRST_SELECT_POINT, - target: SelectState.CHECK_SELECTION, - }, - ESC: { - actions: SelectAction.INIT_SELECT_TOOL, - }, - ENTER: { - target: SelectState.SELECTION_COMPLETED, - }, - DELETE: { - actions: SelectAction.DELETE_SELECTED_ENTITIES, - target: SelectState.INIT, - }, - }, - }, - [SelectState.CHECK_SELECTION]: { - description: - 'Checking to select one line or start drawing a selection rectangle', - meta: { - instructions: - 'Select one line or start drawing a selection rectangle', - }, - always: [ - { - // User started drawing a selection rectangle - guard: ({ context }: { context: SelectContext }) => - !!context.startPoint, - target: SelectState.WAITING_FOR_SECOND_SELECT_POINT, - }, - { - // User clicked on an entity - guard: ({ context }: { context: SelectContext }) => - !context.startPoint, - target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, - }, - ], - }, - [SelectState.WAITING_FOR_SECOND_SELECT_POINT]: { - description: 'Select the second point of a selection rectangle', - meta: { - instructions: 'Select the second point of a selection rectangle', - }, - on: { - DRAW: { - actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE, - }, - MOUSE_CLICK: { - actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE, - target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, - }, - ESC: { - target: SelectState.INIT, - }, - }, - }, - [SelectState.SELECTION_COMPLETED]: { - description: 'Selection completed', - type: 'final', - }, - }, - }, - { - actions: { - INIT_SELECT_TOOL: () => { - setShouldDrawHelpers(false); - setGhostHelperEntities([]); - }, - HANDLE_FIRST_SELECT_POINT: assign( - ({ context, event }: { context: SelectContext; event: StateEvent }) => { - return handleFirstSelectionPoint(context, event as MouseClickEvent); - }, - ), - DRAW_TEMP_SELECTION_RECTANGLE: ({ - context, - event, - }: { - context: SelectContext; - event: StateEvent; - }) => { - if (!context.startPoint) { - // assert - throw new Error( - '[SELECT] Calling drawTempSelectionRectangle without startPoint set', - ); - } - drawTempSelectionRectangle( - context.startPoint as Point, - (event as DrawEvent).drawController.getWorldMouseLocation(), - ); - }, - SELECT_ENTITIES_INSIDE_RECTANGLE: ({ - context, - event, - }: { - context: SelectContext; - event: StateEvent; - }) => { - if (!context.startPoint) { - // - throw new Error( - '[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point', - ); - } - selectEntitiesInsideRectangle( - context.startPoint, - (event as MouseClickEvent).worldMouseLocation, - (event as MouseClickEvent).holdingCtrl, - // (event as MouseClickEvent).holdingShift, - ); - setGhostHelperEntities([]); - }, - DELETE_SELECTED_ENTITIES: () => { - setEntities(getNotSelectedEntities(), true); - setSelectedEntityIds([]); - setGhostHelperEntities([]); - }, - RESET_SELECTION: assign(() => { - setGhostHelperEntities([]); - setSelectedEntityIds([]); - return { - startPoint: null, - }; - }), - }, - }, + { + types: {} as { + context: SelectContext; + events: StateEvent; + }, + context: { + startPoint: null, + type: Tool.SELECT, + }, + initial: SelectState.INIT, + states: { + [SelectState.INIT]: { + description: 'Initializing the select tool', + always: { + actions: SelectAction.INIT_SELECT_TOOL, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + }, + [SelectState.WAITING_FOR_FIRST_SELECT_POINT]: { + description: 'Select a line or select the first point of a selection rectangle', + meta: { + instructions: 'Select a line or start drawing a selection rectangle', + }, + on: { + MOUSE_CLICK: { + actions: SelectAction.HANDLE_FIRST_SELECT_POINT, + target: SelectState.CHECK_SELECTION, + }, + ESC: { + actions: SelectAction.INIT_SELECT_TOOL, + }, + ENTER: { + target: SelectState.SELECTION_COMPLETED, + }, + DELETE: { + actions: SelectAction.DELETE_SELECTED_ENTITIES, + target: SelectState.INIT, + }, + }, + }, + [SelectState.CHECK_SELECTION]: { + description: 'Checking to select one line or start drawing a selection rectangle', + meta: { + instructions: 'Select one line or start drawing a selection rectangle', + }, + always: [ + { + // User started drawing a selection rectangle + guard: ({ context }: { context: SelectContext }) => !!context.startPoint, + target: SelectState.WAITING_FOR_SECOND_SELECT_POINT, + }, + { + // User clicked on an entity + guard: ({ context }: { context: SelectContext }) => !context.startPoint, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + ], + }, + [SelectState.WAITING_FOR_SECOND_SELECT_POINT]: { + description: 'Select the second point of a selection rectangle', + meta: { + instructions: 'Select the second point of a selection rectangle', + }, + on: { + DRAW: { + actions: SelectAction.DRAW_TEMP_SELECTION_RECTANGLE, + }, + MOUSE_CLICK: { + actions: SelectAction.SELECT_ENTITIES_INSIDE_RECTANGLE, + target: SelectState.WAITING_FOR_FIRST_SELECT_POINT, + }, + ESC: { + target: SelectState.INIT, + }, + }, + }, + [SelectState.SELECTION_COMPLETED]: { + description: 'Selection completed', + type: 'final', + }, + }, + }, + { + actions: { + INIT_SELECT_TOOL: () => { + setShouldDrawHelpers(false); + setGhostHelperEntities([]); + }, + HANDLE_FIRST_SELECT_POINT: assign( + ({ context, event }: { context: SelectContext; event: StateEvent }) => { + return handleFirstSelectionPoint(context, event as MouseClickEvent); + } + ), + DRAW_TEMP_SELECTION_RECTANGLE: ({ + context, + event, + }: { + context: SelectContext; + event: StateEvent; + }) => { + if (!context.startPoint) { + // assert + throw new Error('[SELECT] Calling drawTempSelectionRectangle without startPoint set'); + } + drawTempSelectionRectangle( + context.startPoint as Point, + (event as DrawEvent).drawController.getWorldMouseLocation() + ); + }, + SELECT_ENTITIES_INSIDE_RECTANGLE: ({ + context, + event, + }: { + context: SelectContext; + event: StateEvent; + }) => { + if (!context.startPoint) { + // + throw new Error('[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point'); + } + selectEntitiesInsideRectangle( + context.startPoint, + (event as MouseClickEvent).worldMouseLocation, + (event as MouseClickEvent).holdingCtrl + // (event as MouseClickEvent).holdingShift, + ); + setGhostHelperEntities([]); + }, + DELETE_SELECTED_ENTITIES: () => { + setEntities(getNotSelectedEntities(), true); + setSelectedEntityIds([]); + setGhostHelperEntities([]); + }, + RESET_SELECTION: assign(() => { + setGhostHelperEntities([]); + setSelectedEntityIds([]); + return { + startPoint: null, + }; + }), + }, + } ); diff --git a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts index 182629a8..757e8d39 100644 --- a/B07_DesignDetail/openwebcad/src/tools/tool.types.ts +++ b/B07_DesignDetail/openwebcad/src/tools/tool.types.ts @@ -4,114 +4,110 @@ import type { EventObject } from 'xstate'; import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; export enum ActionType { - Click = 'Click', - TypedCommand = 'TypedCommand', - ActivateTool = 'ActivateTool', + Click = 'Click', + TypedCommand = 'TypedCommand', + ActivateTool = 'ActivateTool', } export interface ClickEvent { - worldMouseLocation: Point; - holdingCtrl: boolean; - holdingShift: boolean; + worldMouseLocation: Point; + holdingCtrl: boolean; + holdingShift: boolean; } export interface TypedCommandEvent { - text: string; + text: string; } export interface ToolHandler { - handleToolActivate(): void; - handleToolClick( - worldMouseLocation: Point, - holdingCtrl: boolean, - holdingShift: boolean, - ): void; - handleToolTypedCommand(command: string): void; + handleToolActivate(): void; + handleToolClick(worldMouseLocation: Point, holdingCtrl: boolean, holdingShift: boolean): void; + handleToolTypedCommand(command: string): void; } export enum ActorEvent { - MOUSE_CLICK = 'MOUSE_CLICK', - ESC = 'ESC', - ENTER = 'ENTER', - DELETE = 'DELETE', - DRAW = 'DRAW', - FILE_SELECTED = 'FILE_SELECTED', - NUMBER_INPUT = 'NUMBER_INPUT', - TEXT_INPUT = 'TEXT_INPUT', - ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT', - RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT', + MOUSE_CLICK = 'MOUSE_CLICK', + ESC = 'ESC', + ENTER = 'ENTER', + DELETE = 'DELETE', + DRAW = 'DRAW', + FILE_SELECTED = 'FILE_SELECTED', + NUMBER_INPUT = 'NUMBER_INPUT', + TEXT_INPUT = 'TEXT_INPUT', + ABSOLUTE_POINT_INPUT = 'ABSOLUTE_POINT_INPUT', + RELATIVE_POINT_INPUT = 'RELATIVE_POINT_INPUT', } export interface MouseClickEvent extends EventObject { - type: ActorEvent.MOUSE_CLICK; - worldMouseLocation: Point; - screenMouseLocation: Point; - holdingCtrl: boolean; - holdingShift: boolean; + type: ActorEvent.MOUSE_CLICK; + worldMouseLocation: Point; + screenMouseLocation: Point; + holdingCtrl: boolean; + holdingShift: boolean; } export interface KeyboardEscEvent extends EventObject { - type: ActorEvent.ESC; + type: ActorEvent.ESC; } export interface KeyboardEnterEvent extends EventObject { - type: ActorEvent.ENTER; + type: ActorEvent.ENTER; } export interface KeyboardDeleteEvent extends EventObject { - type: ActorEvent.DELETE; + type: ActorEvent.DELETE; } export interface NumberInputEvent extends EventObject { - type: ActorEvent.NUMBER_INPUT; - value: number; - worldMouseLocation: Point; + type: ActorEvent.NUMBER_INPUT; + value: number; + worldMouseLocation: Point; } export interface TextInputEvent extends EventObject { - type: ActorEvent.TEXT_INPUT; - value: string; + type: ActorEvent.TEXT_INPUT; + value: string; } export interface AbsolutePointInputEvent extends EventObject { - type: ActorEvent.ABSOLUTE_POINT_INPUT; - value: Point; + type: ActorEvent.ABSOLUTE_POINT_INPUT; + value: Point; } export interface RelativePointInputEvent extends EventObject { - type: ActorEvent.RELATIVE_POINT_INPUT; - value: Point; + type: ActorEvent.RELATIVE_POINT_INPUT; + value: Point; } export interface FileSelectedEvent extends EventObject { - type: ActorEvent.FILE_SELECTED; - image: HTMLImageElement; + type: ActorEvent.FILE_SELECTED; + image: HTMLImageElement; } export interface DrawEvent extends EventObject { - type: ActorEvent.DRAW; - drawController: ScreenCanvasDrawController; + type: ActorEvent.DRAW; + drawController: ScreenCanvasDrawController; } export type PointInputEvent = - | DrawEvent - | MouseClickEvent - | NumberInputEvent - | AbsolutePointInputEvent - | RelativePointInputEvent; + | DrawEvent + | MouseClickEvent + | NumberInputEvent + | AbsolutePointInputEvent + | RelativePointInputEvent; export type StateEvent = - | MouseClickEvent - | KeyboardEscEvent - | KeyboardEnterEvent - | KeyboardDeleteEvent - | NumberInputEvent - | TextInputEvent - | AbsolutePointInputEvent - | RelativePointInputEvent - | FileSelectedEvent - | DrawEvent; + | MouseClickEvent + | KeyboardEscEvent + | KeyboardEnterEvent + | KeyboardDeleteEvent + | NumberInputEvent + | TextInputEvent + | AbsolutePointInputEvent + | RelativePointInputEvent + | FileSelectedEvent + | DrawEvent; export interface ToolContext { - type: Tool; + type: Tool; } diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts index b20d8ecd..c5017685 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/clipboard-tools.ts @@ -1,6 +1,10 @@ /** 클립보드 명령 — 잘라내기·복사·붙여넣기 (조사표 3절 클립보드 패널) */ import { toast } from 'react-toastify'; -import { copyToClipboard, hasClipboardContent, pasteFromClipboard } from '../../helpers/cad-clipboard'; +import { + copyToClipboard, + hasClipboardContent, + pasteFromClipboard, +} from '../../helpers/cad-clipboard'; import { addEntities, deleteEntities, diff --git a/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts b/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts index 32b97f24..24f6a55a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools/utility/property-tools.ts @@ -1,6 +1,10 @@ /** 특성 명령 — 투명도와 특성 팔레트 열기 (조사표 3절 특성 패널) */ import { toast } from 'react-toastify'; -import { openInspector, setQuickPropertiesVisible, isQuickPropertiesVisible } from '../../components/ui-state'; +import { + openInspector, + setQuickPropertiesVisible, + isQuickPropertiesVisible, +} from '../../components/ui-state'; import { getEntities, setEntities, setSelectedEntityIds } from '../../state'; import { Tool } from '../../tools'; import { createSequenceTool } from '../factories/sequence-tool'; @@ -70,7 +74,11 @@ export const lineTypeToolStateMachine = createSequenceTool({ helpers: false, steps: [ { kind: 'selection', instructions: '선종류를 바꿀 객체를 선택한 뒤 ENTER.' }, - { kind: 'text', instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', defaultValue: '실선' }, + { + kind: 'text', + instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', + defaultValue: '실선', + }, ], commit: (input) => { const key = input.text(1).trim(); diff --git a/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json b/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json index 4db26e47..35e8ba75 100644 --- a/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json +++ b/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json @@ -1,60 +1,60 @@ { - "title": "circle 5", - "selectorAttribute": "data-id", - "steps": [ + "title": "circle 5", + "selectorAttribute": "data-id", + "steps": [ + { + "type": "setViewport", + "width": 1278, + "height": 1430, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:5173/", + "assertedEvents": [ { - "type": "setViewport", - "width": 1278, - "height": 1430, - "deviceScaleFactor": 1, - "isMobile": false, - "hasTouch": false, - "isLandscape": false - }, - { - "type": "navigate", - "url": "http://localhost:5173/", - "assertedEvents": [ - { - "type": "navigation", - "url": "http://localhost:5173/", - "title": "Open WebCAD" - } - ] - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='circle-button'] svg"], - ["xpath///*[@data-id=\"circle-button\"]/div/svg"], - ["pierce/[data-id='circle-button'] svg"], - ["aria/Circle (c)", "aria/[role=\"image\"]"] - ], - "offsetY": 14, - "offsetX": 7 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 257, - "offsetX": 325 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 424, - "offsetX": 366 + "type": "navigation", + "url": "http://localhost:5173/", + "title": "Open WebCAD" } - ] + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='circle-button'] svg"], + ["xpath///*[@data-id=\"circle-button\"]/div/svg"], + ["pierce/[data-id='circle-button'] svg"], + ["aria/Circle (c)", "aria/[role=\"image\"]"] + ], + "offsetY": 14, + "offsetX": 7 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 257, + "offsetX": 325 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 424, + "offsetX": 366 + } + ] } diff --git a/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts b/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts index f8724213..2ef2e5ed 100644 --- a/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts +++ b/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts @@ -2,38 +2,35 @@ /* * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework */ -import {expect, test} from 'vitest'; -import {getEntities} from '../../../src/state'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import {initApplication} from '../../helpers/init-application'; -import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; -import type {CircleJsonData} from '../../../src/entities/CircleEntity'; +import { expect, test } from 'vitest'; +import { getActiveLineColor, getEntities } from '../../../src/state'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import { initApplication } from '../../helpers/init-application'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; +import type { CircleJsonData } from '../../../src/entities/CircleEntity'; import circleRecording from './circle.recording.json'; -import {replayRecording} from '../../helpers/replay-recording'; +import { replayRecording } from '../../helpers/replay-recording'; test('Draw circle', async () => { - const inputController = initApplication(); + const inputController = initApplication(); - replayRecording(inputController, circleRecording); + replayRecording(inputController, circleRecording); - const entities = getEntities(); + const entities = getEntities(); - expect(entities).toHaveLength(1); + expect(entities).toHaveLength(1); - const circleEntity = entities[0]; - expect(circleEntity.getType()).toBe(EntityName.Circle); - const circleJson = - (await circleEntity.toJson()) as JsonEntity; + const circleEntity = entities[0]; + expect(circleEntity.getType()).toBe(EntityName.Circle); + const circleJson = (await circleEntity.toJson()) as JsonEntity; - expect(circleJson.lineColor).toBe('#fff'); - expect(circleJson.lineWidth).toBe(1); - expect(circleJson.type).toBe('Circle'); - expect(circleJson.shapeData.center.x).toBe(325); - expect(circleJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 257); + expect(circleJson.lineColor).toBe(getActiveLineColor()); + expect(circleJson.lineWidth).toBe(1); + expect(circleJson.type).toBe('Circle'); + expect(circleJson.shapeData.center.x).toBe(325); + expect(circleJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 257); - const diffX = 424 - 257; - const diffY = 366 - 325; - expect(circleJson.shapeData.radius).toBe( - Math.sqrt(diffX * diffX + diffY * diffY), - ); + const diffX = 424 - 257; + const diffY = 366 - 325; + expect(circleJson.shapeData.radius).toBe(Math.sqrt(diffX * diffX + diffY * diffY)); }); diff --git a/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts b/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts index 800b255e..b17cf536 100644 --- a/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts +++ b/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts @@ -2,32 +2,32 @@ /* * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework */ -import {expect, test} from 'vitest'; -import {getEntities} from '../../../src/state'; -import {Tool} from '../../../src/tools'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import {initApplication} from '../../helpers/init-application'; -import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; -import {click} from '../../helpers/click'; -import type {LineJsonData} from '../../../src/entities/LineEntity'; -import {setActiveTool} from '../../helpers/set-active-tool'; +import { expect, test } from 'vitest'; +import { getActiveLineColor, getEntities } from '../../../src/state'; +import { Tool } from '../../../src/tools'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import { initApplication } from '../../helpers/init-application'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; +import { click } from '../../helpers/click'; +import type { LineJsonData } from '../../../src/entities/LineEntity'; +import { setActiveTool } from '../../helpers/set-active-tool'; test('Draw line', async () => { - const inputController = initApplication(); - setActiveTool(Tool.LINE); - click(inputController, 185, 94); - click(inputController, 740, 395); - const entities = getEntities(); + const inputController = initApplication(); + setActiveTool(Tool.LINE); + click(inputController, 185, 94); + click(inputController, 740, 395); + const entities = getEntities(); - const lineEntity = entities[0]; - expect(lineEntity.getType()).toBe(EntityName.Line); - const lineJson = (await lineEntity.toJson()) as JsonEntity; + const lineEntity = entities[0]; + expect(lineEntity.getType()).toBe(EntityName.Line); + const lineJson = (await lineEntity.toJson()) as JsonEntity; - expect(lineJson.lineColor).toBe('#fff'); - expect(lineJson.lineWidth).toBe(1); - expect(lineJson.type).toBe('Line'); - expect(lineJson.shapeData.startPoint.x).toBe(185); - expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 94); - expect(lineJson.shapeData.endPoint.x).toBe(740); - expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 395); + expect(lineJson.lineColor).toBe(getActiveLineColor()); + expect(lineJson.lineWidth).toBe(1); + expect(lineJson.type).toBe('Line'); + expect(lineJson.shapeData.startPoint.x).toBe(185); + expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 94); + expect(lineJson.shapeData.endPoint.x).toBe(740); + expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 395); }); diff --git a/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts b/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts index be0e7d17..6113e3f0 100644 --- a/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts +++ b/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts @@ -2,15 +2,15 @@ /* * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework */ -import {expect, test} from 'vitest'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import type {RectangleJsonData} from '../../../src/entities/RectangleEntity'; -import {getEntities} from '../../../src/state'; -import {Tool} from '../../../src/tools'; -import {click} from '../../helpers/click'; -import {initApplication} from '../../helpers/init-application'; -import {setActiveTool} from '../../helpers/set-active-tool'; -import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; +import { expect, test } from 'vitest'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import type { RectangleJsonData } from '../../../src/entities/RectangleEntity'; +import { getActiveLineColor, getEntities } from '../../../src/state'; +import { Tool } from '../../../src/tools'; +import { click } from '../../helpers/click'; +import { initApplication } from '../../helpers/init-application'; +import { setActiveTool } from '../../helpers/set-active-tool'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; test('Draw circle', async () => { const inputController = initApplication(); @@ -23,7 +23,7 @@ test('Draw circle', async () => { expect(rectangleEntity.getType()).toBe(EntityName.Rectangle); const rectangleJson = (await rectangleEntity.toJson()) as JsonEntity; - expect(rectangleJson.lineColor).toBe('#fff'); + expect(rectangleJson.lineColor).toBe(getActiveLineColor()); expect(rectangleJson.lineWidth).toBe(1); expect(rectangleJson.type).toBe('Rectangle'); expect(rectangleJson.shapeData.points[0].x).toBe(185); diff --git a/B07_DesignDetail/openwebcad/test/helpers/click.ts b/B07_DesignDetail/openwebcad/test/helpers/click.ts index 38b980c7..31c45275 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/click.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/click.ts @@ -1,6 +1,5 @@ -import {TOOLBAR_WIDTH} from '../../src/App.consts'; -import {MouseButton} from '../../src/App.types'; -import type {InputController} from '../../src/inputController/input-controller'; +import { MouseButton } from '../../src/App.types'; +import type { InputController } from '../../src/inputController/input-controller'; /** * Trigger a click event on the canvas @@ -17,7 +16,8 @@ export function click( ) { inputController.handleMouseUp({ button: mouseButton, - clientX: TOOLBAR_WIDTH + x, // Coordinates are relative to the top left of the draw area excluding the toolbar + // 입력 컨트롤러가 캔버스 bounding rect(시험에서는 0)를 빼므로 화면 좌표를 그대로 준다. + clientX: x, clientY: y, preventDefault: () => {}, stopPropagation: () => {}, diff --git a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts index 3d99fee4..bda67434 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/init-application.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/init-application.ts @@ -1,12 +1,17 @@ -import {Point} from '@flatten-js/core'; -import {Actor} from 'xstate'; -import type {ScreenCanvasDrawController} from '../../src/drawControllers/screenCanvas.drawController'; -import {InputController} from '../../src/inputController/input-controller'; -import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state'; -import {Tool} from '../../src/tools'; -import {TOOL_STATE_MACHINES} from '../../src/commands/registry'; -import {ScreenCanvasDrawController as ScreenCanvasDrawControllerMock} from '../mocks/drawControllers/screenCanvas.drawController'; -import {CANVAS_HEIGHT, CANVAS_WIDTH} from './tests.consts'; +import { Point } from '@flatten-js/core'; +import { Actor } from 'xstate'; +import type { ScreenCanvasDrawController } from '../../src/drawControllers/screenCanvas.drawController'; +import { InputController } from '../../src/inputController/input-controller'; +import { + setActiveToolActor, + setEntities, + setInputController, + setScreenCanvasDrawController, +} from '../../src/state'; +import { Tool } from '../../src/tools'; +import { TOOL_STATE_MACHINES } from '../../src/commands/registry'; +import { ScreenCanvasDrawController as ScreenCanvasDrawControllerMock } from '../mocks/drawControllers/screenCanvas.drawController'; +import { CANVAS_HEIGHT, CANVAS_WIDTH } from './tests.consts'; export function initApplication(): InputController { const inputController = new InputController(); diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts index a6711309..749e8560 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts @@ -1,8 +1,8 @@ -import type {InputController} from '../../src/inputController/input-controller'; -import {Tool} from '../../src/tools'; -import {click} from './click'; -import type {Recording, Step} from './replay-recording.types'; -import {setActiveTool} from './set-active-tool'; +import type { InputController } from '../../src/inputController/input-controller'; +import { Tool } from '../../src/tools'; +import { click } from './click'; +import type { Recording, Step } from './replay-recording.types'; +import { setActiveTool } from './set-active-tool'; const DATA_ID_TO_TOOL_NAME: Record = { 'select-button': Tool.SELECT, diff --git a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts index d0c90b97..adf621b8 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/replay-recording.types.ts @@ -1,28 +1,28 @@ export interface Recording { - title: string; - selectorAttribute: string; - steps: Step[]; + title: string; + selectorAttribute: string; + steps: Step[]; } export interface Step { - type: string; - width?: number; - height?: number; - deviceScaleFactor?: number; - isMobile?: boolean; - hasTouch?: boolean; - isLandscape?: boolean; - url?: string; - assertedEvents?: AssertedEvent[]; - target?: string; - selectors?: string[][]; - offsetY?: number; - offsetX?: number; - key?: string; + type: string; + width?: number; + height?: number; + deviceScaleFactor?: number; + isMobile?: boolean; + hasTouch?: boolean; + isLandscape?: boolean; + url?: string; + assertedEvents?: AssertedEvent[]; + target?: string; + selectors?: string[][]; + offsetY?: number; + offsetX?: number; + key?: string; } export interface AssertedEvent { - type: string; - url: string; - title: string; + type: string; + url: string; + title: string; } diff --git a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts index 83b5cb3f..370e4dbc 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/set-active-tool.ts @@ -4,8 +4,8 @@ import { Actor } from 'xstate'; import { TOOL_STATE_MACHINES } from '../../src/commands/registry'; export function setActiveTool(toolName: Tool) { - getActiveToolActor()?.stop(); + getActiveToolActor()?.stop(); - const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); - setActiveToolActor(newToolActor); + const newToolActor = new Actor(TOOL_STATE_MACHINES[toolName]); + setActiveToolActor(newToolActor); } diff --git a/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts b/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts index 54701e4f..1ded2825 100644 --- a/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts +++ b/B07_DesignDetail/openwebcad/test/helpers/tests.consts.ts @@ -1,4 +1,4 @@ -import {TOOLBAR_WIDTH} from "../../src/App.consts"; - -export const CANVAS_WIDTH = 1920 - TOOLBAR_WIDTH; +// 좌표 변환이 캔버스 bounding rect 를 쓰도록 바뀌어 툴바 폭 상수가 없어졌다 — +// 시험 캔버스는 화면 전체 폭을 그대로 쓴다. +export const CANVAS_WIDTH = 1920; export const CANVAS_HEIGHT = 1080; diff --git a/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts index 6dc45847..f417cc83 100644 --- a/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ // noinspection JSUnusedLocalSymbols -import {Point, type Vector} from '@flatten-js/core'; -import type {DrawController} from '../../../src/drawControllers/DrawController'; -import {triggerReactUpdate} from '../../../src/state'; -import {StateVariable} from '../../../src/helpers/undo-stack'; -import {MOUSE_ZOOM_MULTIPLIER} from '../../../src/App.consts'; -import {mapNumberRange} from '../../../src/helpers/map-number-range'; +import { Point, type Vector } from '@flatten-js/core'; +import type { DrawController } from '../../../src/drawControllers/DrawController'; +import { triggerReactUpdate } from '../../../src/state'; +import { StateVariable } from '../../../src/helpers/undo-stack'; +import { MOUSE_ZOOM_MULTIPLIER } from '../../../src/App.consts'; +import { mapNumberRange } from '../../../src/helpers/map-number-range'; /** * Screen coordinate system: @@ -29,289 +29,267 @@ import {mapNumberRange} from '../../../src/helpers/map-number-range'; * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ export class ScreenCanvasDrawController implements DrawController { - private screenOffset: Point = new Point(0, 0); - private screenScale = 1; - private worldMouseLocation: Point; + private screenOffset: Point = new Point(0, 0); + private screenScale = 1; + private worldMouseLocation: Point; - constructor( - private context: CanvasRenderingContext2D | null, - private canvasSize: Point, - ) { - this.worldMouseLocation = this.targetToWorld( - new Point(canvasSize.x / 2, canvasSize.y / 2), - ); - this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down - } + constructor( + private context: CanvasRenderingContext2D | null, + private canvasSize: Point + ) { + this.worldMouseLocation = this.targetToWorld(new Point(canvasSize.x / 2, canvasSize.y / 2)); + this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down + } - public getCanvasSize() { - return this.canvasSize; - } + public getCanvasSize() { + return this.canvasSize; + } - public getScreenScale() { - return this.screenScale; - } + public getScreenScale() { + return this.screenScale; + } - public setScreenScale(newScreenScale: number) { - this.screenScale = newScreenScale; - triggerReactUpdate(StateVariable.screenZoom); - } + public setScreenScale(newScreenScale: number) { + this.screenScale = newScreenScale; + triggerReactUpdate(StateVariable.screenZoom); + } - public getScreenOffset() { - return this.screenOffset; - } + public getScreenOffset() { + return this.screenOffset; + } - public setScreenOffset(newScreenOffset: Point) { - this.screenOffset = newScreenOffset; - triggerReactUpdate(StateVariable.screenOffset); - } + public setScreenOffset(newScreenOffset: Point) { + this.screenOffset = newScreenOffset; + triggerReactUpdate(StateVariable.screenOffset); + } - public setScreenMouseLocation(newScreenMouseLocation: Point): void { - this.worldMouseLocation = this.targetToWorld(newScreenMouseLocation); - triggerReactUpdate(StateVariable.screenMouseLocation); - } + public setScreenMouseLocation(newScreenMouseLocation: Point): void { + this.worldMouseLocation = this.targetToWorld(newScreenMouseLocation); + triggerReactUpdate(StateVariable.screenMouseLocation); + } - public getWorldMouseLocation(): Point { - return this.worldMouseLocation; - } + public getWorldMouseLocation(): Point { + return this.worldMouseLocation; + } - public getScreenMouseLocation(): Point { - return this.worldToTarget(this.worldMouseLocation); - } + public getScreenMouseLocation(): Point { + return this.worldToTarget(this.worldMouseLocation); + } - public panScreen(screenOffsetX: number, screenOffsetY: number) { - this.screenOffset = new Point( - this.screenOffset.x - screenOffsetX / this.screenScale, - this.screenOffset.y - screenOffsetY / this.screenScale, - ); - } + public panScreen(screenOffsetX: number, screenOffsetY: number) { + this.screenOffset = new Point( + this.screenOffset.x - screenOffsetX / this.screenScale, + this.screenOffset.y - screenOffsetY / this.screenScale + ); + } - /** - * This function takes the deltaY from the mouse wheel event and zooms the screen in or out - * The location of the mouse in world space is preserved - * @param deltaY - */ - public zoomScreen(deltaY: number) { - const worldMouseLocationBeforeZoom = this.getWorldMouseLocation(); - const newScreenScale = - this.getScreenScale() * - (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY))); - this.setScreenScale(newScreenScale); + /** + * This function takes the deltaY from the mouse wheel event and zooms the screen in or out + * The location of the mouse in world space is preserved + * @param deltaY + */ + public zoomScreen(deltaY: number) { + const worldMouseLocationBeforeZoom = this.getWorldMouseLocation(); + const newScreenScale = + this.getScreenScale() * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY))); + this.setScreenScale(newScreenScale); - // now get the location of the cursor in world space again - // It will have changed because the scale has changed, - // but we can offset our world now to fix the zoom location in screen space, - // because we know how much it changed laterally between the two spatial scales. - const worldMouseLocationAfterZoom = this.getWorldMouseLocation(); + // now get the location of the cursor in world space again + // It will have changed because the scale has changed, + // but we can offset our world now to fix the zoom location in screen space, + // because we know how much it changed laterally between the two spatial scales. + const worldMouseLocationAfterZoom = this.getWorldMouseLocation(); - // Adjust the screen offset to maintain the cursor position - this.screenOffset = new Point( - this.screenOffset.x + - (worldMouseLocationBeforeZoom.x - - worldMouseLocationAfterZoom.x), - this.screenOffset.y + - (worldMouseLocationBeforeZoom.y - - worldMouseLocationAfterZoom.y), - ); - } + // Adjust the screen offset to maintain the cursor position + this.screenOffset = new Point( + this.screenOffset.x + (worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x), + this.screenOffset.y + (worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y) + ); + } - /** - * Convert coordinates from World Space --> Screen Space - */ - public worldToTarget(worldCoordinate: Point): Point { - return new Point( - mapNumberRange( - worldCoordinate.x, - this.screenOffset.x, - this.screenOffset.x + this.canvasSize.x / this.screenScale, - 0, - this.canvasSize.x, - ), - mapNumberRange( - worldCoordinate.y, - this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left - this.screenOffset.y, - 0, - this.canvasSize.y, - ), - ); - } + /** + * Convert coordinates from World Space --> Screen Space + */ + public worldToTarget(worldCoordinate: Point): Point { + return new Point( + mapNumberRange( + worldCoordinate.x, + this.screenOffset.x, + this.screenOffset.x + this.canvasSize.x / this.screenScale, + 0, + this.canvasSize.x + ), + mapNumberRange( + worldCoordinate.y, + this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left + this.screenOffset.y, + 0, + this.canvasSize.y + ) + ); + } - public worldsToTargets(worldCoordinates: Point[]): Point[] { - return worldCoordinates.map(this.worldToTarget.bind(this)); - } + public worldsToTargets(worldCoordinates: Point[]): Point[] { + return worldCoordinates.map(this.worldToTarget.bind(this)); + } - /** - * Convert coordinates from Screen Space --> World Space - * (0, 0) (1920, 0) - * - * (0, 1080) (1920, 1080) - * - * convert to - * - * (0, 1080) (1920, 1080) - * - * (0, 0) (1920, 0) - */ - public targetToWorld(screenCoordinate: Point): Point { - // map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale() - return new Point( - mapNumberRange( - screenCoordinate.x, - 0, - this.canvasSize.x, - this.screenOffset.x, - this.screenOffset.x + this.canvasSize.x / this.screenScale, - ), - mapNumberRange( - screenCoordinate.y, - 0, - this.canvasSize.y, - this.screenOffset.y, - this.screenOffset.y + this.canvasSize.y / this.screenScale, - ), - ); - } + /** + * Convert coordinates from Screen Space --> World Space + * (0, 0) (1920, 0) + * + * (0, 1080) (1920, 1080) + * + * convert to + * + * (0, 1080) (1920, 1080) + * + * (0, 0) (1920, 0) + */ + public targetToWorld(screenCoordinate: Point): Point { + // map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale() + return new Point( + mapNumberRange( + screenCoordinate.x, + 0, + this.canvasSize.x, + this.screenOffset.x, + this.screenOffset.x + this.canvasSize.x / this.screenScale + ), + mapNumberRange( + screenCoordinate.y, + 0, + this.canvasSize.y, + this.screenOffset.y, + this.screenOffset.y + this.canvasSize.y / this.screenScale + ) + ); + } - public targetsToWorlds(screenCoordinates: Point[]): Point[] { - return screenCoordinates.map(this.targetToWorld.bind(this)); - } + public targetsToWorlds(screenCoordinates: Point[]): Point[] { + return screenCoordinates.map(this.targetToWorld.bind(this)); + } - public setLineStyles( - isHighlighted: boolean, - isSelected: boolean, - color: string, - lineWidth: number, - dash: number[] = [], - ) {} + public setLineStyles( + isHighlighted: boolean, + isSelected: boolean, + color: string, + lineWidth: number, + dash: number[] = [] + ) {} - public setFillStyles(fillColor: string) {} + public setFillStyles(fillColor: string) {} - public clear() {} + public clear() {} - /** - * Draws a line from startPoint to endPoint and auto converts to screen space first - * @param worldStartPoint - * @param worldEndPoint - */ - public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {} + /** + * Draws a line from startPoint to endPoint and auto converts to screen space first + * @param worldStartPoint + * @param worldEndPoint + */ + public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {} - /** - * Needs to be public to draw UI that is zoom independent, like snap point indicators - * @param screenStartPoint - * @param screenEndPoint - */ - public drawLineScreen( - screenStartPoint: Point, - screenEndPoint: Point, - ): void {} + /** + * Needs to be public to draw UI that is zoom independent, like snap point indicators + * @param screenStartPoint + * @param screenEndPoint + */ + public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {} - /** - * Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI - * @param centerPoint - * @param radius - * @param startAngle - * @param endAngle - * @param counterClockWise - */ - public drawArc( - centerPoint: Point, - radius: number, - startAngle: number, - endAngle: number, - counterClockWise: boolean, - ) {} + /** + * Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI + * @param centerPoint + * @param radius + * @param startAngle + * @param endAngle + * @param counterClockWise + */ + public drawArc( + centerPoint: Point, + radius: number, + startAngle: number, + endAngle: number, + counterClockWise: boolean + ) {} - public drawArcScreen( - screenCenterPoint: Point, - screenRadius: number, - startAngle: number, - endAngle: number, - counterClockWise: boolean, - ) {} + public drawArcScreen( + screenCenterPoint: Point, + screenRadius: number, + startAngle: number, + endAngle: number, + counterClockWise: boolean + ) {} - /** - * Draw some text at the base location - * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label - * @param label - * @param basePoint - * @param options - */ - public drawText( - label: string, - basePoint: Point, - options: Partial<{ - textDirection?: Vector; - textAlign: 'left' | 'center' | 'right'; - textColor: string; - fontSize: number; - fontFamily: string; - }> = {}, - ): void {} + /** + * Draw some text at the base location + * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label + * @param label + * @param basePoint + * @param options + */ + public drawText( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }> = {} + ): void {} - /** - * Draw some text at the base location - * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text - * @param label - * @param basePoint - * @param options - */ - public drawTextScreen( - label: string, - basePoint: Point, - options: Partial<{ - textDirection?: Vector; - textAlign: 'left' | 'center' | 'right'; - textColor: string; - fontSize: number; - fontFamily: string; - }> = {}, - ): void {} + /** + * Draw some text at the base location + * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text + * @param label + * @param basePoint + * @param options + */ + public drawTextScreen( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }> = {} + ): void {} - /** - * Draw an image to the canvas using world coordinates - * @param imageElement - * @param xMin - * @param yMin - * @param width - * @param height - * @param angle - */ - public drawImage( - imageElement: HTMLImageElement, - xMin: number, - yMin: number, - width: number, - height: number, - angle: number, - ): void {} + /** + * Draw an image to the canvas using world coordinates + * @param imageElement + * @param xMin + * @param yMin + * @param width + * @param height + * @param angle + */ + public drawImage( + imageElement: HTMLImageElement, + xMin: number, + yMin: number, + width: number, + height: number, + angle: number + ): void {} - public fillRect( - xMin: number, - yMin: number, - width: number, - height: number, - color: string, - ) {} + public fillRect(xMin: number, yMin: number, width: number, height: number, color: string) {} - /** - * Fill rectangle with color, but interpret the provided coordinates as screen coordinates - * @param xMin - * @param yMin - * @param width - * @param height - * @param color - */ - public fillRectScreen( - xMin: number, - yMin: number, - width: number, - height: number, - color: string, - ) {} + /** + * Fill rectangle with color, but interpret the provided coordinates as screen coordinates + * @param xMin + * @param yMin + * @param width + * @param height + * @param color + */ + public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {} - /** - * Fill polygon with color - * @param points - */ - public fillPolygon(...points: Point[]) {} + /** + * Fill polygon with color + * @param points + */ + public fillPolygon(...points: Point[]) {} } diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json index ad61f1bd..6c176171 100644 --- a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json @@ -1,163 +1,163 @@ { - "title": "record eraser 2", - "selectorAttribute": "data-id", - "steps": [ + "title": "record eraser 2", + "selectorAttribute": "data-id", + "steps": [ + { + "type": "setViewport", + "width": 1278, + "height": 1430, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:5173/", + "assertedEvents": [ { - "type": "setViewport", - "width": 1278, - "height": 1430, - "deviceScaleFactor": 1, - "isMobile": false, - "hasTouch": false, - "isLandscape": false - }, - { - "type": "navigate", - "url": "http://localhost:5173/", - "assertedEvents": [ - { - "type": "navigation", - "url": "http://localhost:5173/", - "title": "Open WebCAD" - } - ] - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 148, - "offsetX": 473 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 698, - "offsetX": 473 - }, - { - "type": "keyDown", - "target": "main", - "key": "c" - }, - { - "type": "keyUp", - "key": "c", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "i" - }, - { - "type": "keyUp", - "key": "i", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "r" - }, - { - "type": "keyUp", - "key": "r", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "c" - }, - { - "type": "keyUp", - "key": "c", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "l" - }, - { - "type": "keyUp", - "key": "l", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "e" - }, - { - "type": "keyUp", - "key": "e", - "target": "main" - }, - { - "type": "keyDown", - "target": "main", - "key": "Enter" - }, - { - "type": "keyUp", - "key": "Enter", - "target": "main" - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 402, - "offsetX": 266 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 441, - "offsetX": 542 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='delete-segment-button'] path"], - ["xpath///*[@data-id=\"delete-segment-button\"]/div/svg/path"], - ["pierce/[data-id='delete-segment-button'] path"], - ["aria/Delete segments", "aria/[role=\"graphics-symbol\"]"] - ], - "offsetY": 15, - "offsetX": 9 - }, - { - "type": "click", - "target": "main", - "selectors": [ - ["[data-id='canvas']"], - ["xpath///*[@data-id=\"canvas\"]"], - ["pierce/[data-id='canvas']"] - ], - "offsetY": 291, - "offsetX": 524 + "type": "navigation", + "url": "http://localhost:5173/", + "title": "Open WebCAD" } - ] + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 148, + "offsetX": 473 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 698, + "offsetX": 473 + }, + { + "type": "keyDown", + "target": "main", + "key": "c" + }, + { + "type": "keyUp", + "key": "c", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "i" + }, + { + "type": "keyUp", + "key": "i", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "r" + }, + { + "type": "keyUp", + "key": "r", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "c" + }, + { + "type": "keyUp", + "key": "c", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "l" + }, + { + "type": "keyUp", + "key": "l", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "e" + }, + { + "type": "keyUp", + "key": "e", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "Enter" + }, + { + "type": "keyUp", + "key": "Enter", + "target": "main" + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 402, + "offsetX": 266 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 441, + "offsetX": 542 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='delete-segment-button'] path"], + ["xpath///*[@data-id=\"delete-segment-button\"]/div/svg/path"], + ["pierce/[data-id='delete-segment-button'] path"], + ["aria/Delete segments", "aria/[role=\"graphics-symbol\"]"] + ], + "offsetY": 15, + "offsetX": 9 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 291, + "offsetX": 524 + } + ] } diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts index 237c6a41..17f10539 100644 --- a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts @@ -1,13 +1,13 @@ -import {Point} from '@flatten-js/core'; /* eslint-disable @typescript-eslint/no-explicit-any */ -import {expect, test} from 'vitest'; -import type {ArcJsonData} from '../../../src/entities/ArcEntity'; -import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; -import type {LineJsonData} from '../../../src/entities/LineEntity'; -import {pointDistance} from '../../../src/helpers/distance-between-points'; -import {getEntities} from '../../../src/state'; -import {initApplication} from '../../helpers/init-application'; -import {replayRecording} from '../../helpers/replay-recording'; -import {CANVAS_HEIGHT} from "../../helpers/tests.consts"; +import { Point } from '@flatten-js/core'; /* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, test } from 'vitest'; +import type { ArcJsonData } from '../../../src/entities/ArcEntity'; +import { EntityName, type JsonEntity } from '../../../src/entities/Entity'; +import type { LineJsonData } from '../../../src/entities/LineEntity'; +import { pointDistance } from '../../../src/helpers/distance-between-points'; +import { getActiveLineColor, getEntities } from '../../../src/state'; +import { initApplication } from '../../helpers/init-application'; +import { replayRecording } from '../../helpers/replay-recording'; +import { CANVAS_HEIGHT } from '../../helpers/tests.consts'; import eraserRecording from './eraser.recording.json'; test('Draw circle and line and erase part of circle', async () => { @@ -23,7 +23,7 @@ test('Draw circle and line and erase part of circle', async () => { expect(lineEntity.getType()).toBe(EntityName.Line); const lineJson = (await lineEntity.toJson()) as JsonEntity; - expect(lineJson.lineColor).toBe('#fff'); + expect(lineJson.lineColor).toBe(getActiveLineColor()); expect(lineJson.lineWidth).toBe(1); expect(lineJson.type).toBe('Line'); expect(lineJson.shapeData.startPoint.x).toBe(473); @@ -35,7 +35,7 @@ test('Draw circle and line and erase part of circle', async () => { expect(arcEntity.getType()).toBe(EntityName.Arc); const arcJson = (await arcEntity.toJson()) as JsonEntity; - expect(arcJson.lineColor).toBe('#fff'); + expect(arcJson.lineColor).toBe(getActiveLineColor()); expect(arcJson.lineWidth).toBe(1); expect(arcJson.type).toBe('Arc'); expect(arcJson.shapeData.center.x).toBe(266); @@ -45,6 +45,9 @@ test('Draw circle and line and erase part of circle', async () => { new Point(542, CANVAS_HEIGHT - 441) ); expect(arcJson.shapeData.radius).toBeCloseTo(radius, 5); - expect(arcJson.shapeData.startAngle).toBeCloseTo(0.7338182524767606, 5); - expect(arcJson.shapeData.endAngle).toBeCloseTo(-0.7338182524767606, 5); + // 각은 [0, 2π)로 정규화해 비교한다 — 같은 각을 음수로도 2π 더한 값으로도 쓸 수 있다. + const turn = 2 * Math.PI; + const normalize = (angle: number) => ((angle % turn) + turn) % turn; + expect(normalize(arcJson.shapeData.startAngle)).toBeCloseTo(normalize(0.7338182524767606), 5); + expect(normalize(arcJson.shapeData.endAngle)).toBeCloseTo(normalize(-0.7338182524767606), 5); }); diff --git a/common_util/common_util_storage.py b/common_util/common_util_storage.py index 4afb11d3..59b65ffa 100644 --- a/common_util/common_util_storage.py +++ b/common_util/common_util_storage.py @@ -120,3 +120,28 @@ def resolve_stored_project_path(relative_path: str) -> str: os.makedirs(path, exist_ok=True) ensure_project_storage_layout(path) return path + + +def read_stored_asset(relative_path: str | None) -> bytes | None: + """`storage/` 기준 상대 경로의 **파일**을 읽는다. 없거나 수상하면 None. + + 회사 로고·개인 서명처럼 DB에 경로만 담아 두는 자산용이다. + `resolve_stored_project_path()`는 폴더를 만들어 주는 프로젝트 루트용이라 파일에는 + 쓸 수 없다. 검증 규칙(상대 경로·`storage/` 시작·루트 밖 금지)은 같다. + """ + if not relative_path: + return None + normalized = PurePosixPath(str(relative_path).replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + return None + if not normalized.parts or normalized.parts[0] != "storage": + return None + + storage_root = os.path.realpath(STORAGE_BASE_DIR) + path = os.path.realpath(os.path.join(storage_root, *normalized.parts[1:])) + if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root: + return None + if not os.path.isfile(path): + return None + with open(path, "rb") as file: + return file.read() diff --git a/db_management/011_title_block.sql b/db_management/011_title_block.sql new file mode 100644 index 00000000..1a0aeace --- /dev/null +++ b/db_management/011_title_block.sql @@ -0,0 +1,29 @@ +-- 011_title_block.sql +-- 도면 표제란(도각)에 채울 값 (2026-09-02 사용자 지시) +-- +-- 표제란 칸 중 DB에 자리가 없어 빈칸으로 나가던 것들을 만든다. +-- 시행청 · 과업책임자 · 분야별책임자 · 설계자 → projects +-- 회사 로고 → companies, 개인 서명 → users +-- 그림은 파일로 두고 **경로만** 담는다(기존 input_files·storage_path와 같은 결). +-- +-- 전부 ADD COLUMN(NULL 허용)이라 기존 행·기존 동작은 그대로다. 값이 없으면 표제란은 +-- 지금처럼 빈칸으로 나간다. +-- +-- 사람 배정 컬럼에 FK를 걸지 않은 이유: users는 소프트 삭제(deleted_at)라 행이 실제로 +-- 지워지는 일이 드물고, 조회가 LEFT JOIN이라 id가 떠 있어도 이름이 빈칸으로 나갈 뿐 +-- 도면이 깨지지 않는다("값이 없으면 빈칸" 규칙과 같은 결과). FK를 걸면 4환경이 공유하는 +-- DB에서 users 삭제·복구가 서로 막힌다. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS client_org VARCHAR(255) NULL COMMENT '시행청(발주처)' AFTER road_type, + ADD COLUMN IF NOT EXISTS pm_user_id INT NULL COMMENT '과업책임자 (users.id)', + ADD COLUMN IF NOT EXISTS field_lead_user_id INT NULL COMMENT '분야별책임자 (users.id)', + ADD COLUMN IF NOT EXISTS designer_user_id INT NULL COMMENT '설계자 (users.id, 없으면 소유자)'; + +ALTER TABLE companies + ADD COLUMN IF NOT EXISTS logo_path VARCHAR(500) NULL COMMENT '회사 로고 파일 경로 (storage/ 기준 상대)'; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS signature_path VARCHAR(500) NULL COMMENT '개인 서명 파일 경로 (storage/ 기준 상대)'; diff --git a/db_management/012_title_block_inputs.sql b/db_management/012_title_block_inputs.sql new file mode 100644 index 00000000..832e9cd5 --- /dev/null +++ b/db_management/012_title_block_inputs.sql @@ -0,0 +1,15 @@ +-- 012_title_block_inputs.sql +-- 표지에 들어갈 사람이 넣는 값 (2026-09-02) +-- +-- 011 로 시행청·담당자 배정 자리를 냈고, 남은 표지 빈칸 둘은 **지어낼 수 없는 값**이라 +-- 사람이 넣을 칸을 만든다. 값 출처를 프로그램이 정하지 않는다. +-- 연도·기번 — 실무문서 폴더명 관행: `2024년 간선임도(기번3-울진.대흥)` +-- 사업량 — 표지 「- 사 업 량 :」 칸. 단위·표기가 사업 종류마다 달라 자유 문자열로 받는다. +-- +-- 전부 ADD COLUMN(NULL 허용)이라 기존 행·기존 동작은 그대로다. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS project_number VARCHAR(100) NULL COMMENT '표지 연도·기번' AFTER client_org, + ADD COLUMN IF NOT EXISTS work_amount VARCHAR(100) NULL COMMENT '표지 사업량' AFTER project_number; diff --git a/db_management/013_company_assets.sql b/db_management/013_company_assets.sql new file mode 100644 index 00000000..333a7162 --- /dev/null +++ b/db_management/013_company_assets.sql @@ -0,0 +1,34 @@ +-- 013_company_assets.sql +-- 설계일자·도면 자산(로고·서명) 회사 공유 (2026-09-02 사용자 확정) +-- +-- ① 설계일자는 확정일 자동이 아니라 **사용자가 지정 입력**한다. +-- ② 로고·서명은 **회사 안에서 공유하는 목록**으로 관리한다. 사용자 계정에 물릴 수도 있고 +-- 아닐 수도 있다(회사 공용 직인 등). 회사 구성원과 회사 관리자가 작성·수정하고, +-- 시스템 관리자는 전체 권한을 가진다. +-- ③ 도면이 어느 로고·서명을 쓸지는 **프로젝트가 고른다** — 목록에서 골라 물린다. +-- +-- 011 의 `companies.logo_path`·`users.signature_path` 는 이 표가 정본이 되면서 읽지 않는다. +-- 컬럼은 남겨 둔다(4환경 공유 DB 라 지우는 쪽이 위험하다). + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS design_date DATE NULL COMMENT '설계일자 (사용자 지정)' AFTER work_amount, + ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL COMMENT '도면에 쓸 회사 로고 (company_assets.id)', + ADD COLUMN IF NOT EXISTS signature_asset_id INT NULL COMMENT '도면에 쓸 서명 (company_assets.id)'; + +-- 회사 안에서 공유하는 도면 자산. 그림은 파일로 두고 경로만 담는다. +CREATE TABLE IF NOT EXISTS company_assets ( + id INT AUTO_INCREMENT PRIMARY KEY, + company_id INT NOT NULL, + kind ENUM('LOGO', 'SIGNATURE') NOT NULL, + label VARCHAR(100) NOT NULL COMMENT '목록에 보이는 이름', + file_path VARCHAR(500) NOT NULL COMMENT 'storage/ 기준 상대 경로', + user_id INT NULL COMMENT '이 자산의 주인 (없으면 회사 공용)', + created_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL DEFAULT NULL, + INDEX idx_company_assets_company (company_id, kind, deleted_at), + INDEX idx_company_assets_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/main.py b/main.py index b3d4cc96..af402284 100644 --- a/main.py +++ b/main.py @@ -42,6 +42,7 @@ from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_rou from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router +from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router from B06_Section.B06_Section_Router_Confirm import ( @@ -388,6 +389,7 @@ app.include_router(b04_inflow_router, dependencies=protected_with_company) app.include_router(b04_basins_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) +app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) diff --git a/resources/data_global_contours/convert_to_gpkg.py b/resources/data_global_contours/convert_to_gpkg.py index d266eab1..0fb28ce0 100644 --- a/resources/data_global_contours/convert_to_gpkg.py +++ b/resources/data_global_contours/convert_to_gpkg.py @@ -5,12 +5,14 @@ 실행 전 가상환경(venv) 터미널에서 다음 라이브러리를 설치해 주세요: pip install geopandas pyogrio """ + import sys from pathlib import Path import time try: import geopandas as gpd + # fiona 대신 현재 설치된 고속 pyogrio 엔진을 검증 및 사용합니다. import pyogrio except ImportError: @@ -19,43 +21,48 @@ except ImportError: print(">>> pip install geopandas pyogrio") sys.exit(1) + def convert_shp_to_gpkg(): current_dir = Path(__file__).resolve().parent shp_files = sorted(list(current_dir.glob("TN_CTRLN*.shp"))) - + if not shp_files: - print(f"[경고] {current_dir} 경로에서 'TN_CTRLN'으로 시작하는 .shp 파일을 찾을 수 없습니다.") + print( + f"[경고] {current_dir} 경로에서 'TN_CTRLN'으로 시작하는 .shp 파일을 찾을 수 없습니다." + ) return - + gpkg_output_path = current_dir / "national_contours.gpkg" print(f"-> 총 {len(shp_files)}개의 등고선 SHP 파일이 감지되었습니다.") print(f"-> 변환 시작 (저장 경로: {gpkg_output_path})") - + start_time = time.time() - + # 첫 번째 파일 처리 (새 GeoPackage 파일 생성) first_shp = shp_files[0] print(f"\n[1/{len(shp_files)}] {first_shp.name} 읽는 중...") try: # GeoPandas를 이용해 shapefile 로드 (엔진으로 pyogrio 명시하여 고속 로드) gdf = gpd.read_file(first_shp, encoding="cp949", engine="pyogrio") - + # 속성 필드명 표준화 (소문자로 통일하고 cont_val / elev 필드가 있으면 elevation으로 통일) gdf.columns = [col.lower() for col in gdf.columns] for elev_col in ["cont_val", "elev_val", "elevation"]: if elev_col in gdf.columns: gdf["elevation"] = gdf[elev_col].astype(float) break - + # 필요한 필드만 최소한으로 남겨 용량 최소화 keep_cols = ["geometry", "elevation"] if "elevation" in gdf.columns else ["geometry"] gdf = gdf[keep_cols] - + # GeoPackage 파일로 쓰기 (고속 pyogrio 엔진 및 spatial_index 생성 활성화) print(" -> GeoPackage 초기 생성 및 쓰기 중...") - gdf.to_file(gpkg_output_path, layer="contours", driver="GPKG", spatial_index=True, engine="pyogrio") + gdf.to_file( + gpkg_output_path, layer="contours", driver="GPKG", spatial_index=True, engine="pyogrio" + ) print(f" -> 완료 (레코드 수: {len(gdf)}개)") - + except Exception as e: print(f" -> [에러] 첫 번째 파일 처리 중 오류 발생: {e}") return @@ -65,26 +72,35 @@ def convert_shp_to_gpkg(): print(f"\n[{idx}/{len(shp_files)}] {shp_path.name} 읽는 중...") try: gdf_append = gpd.read_file(shp_path, encoding="cp949", engine="pyogrio") - + # 컬럼 표준화 gdf_append.columns = [col.lower() for col in gdf_append.columns] for elev_col in ["cont_val", "elev_val", "elevation"]: if elev_col in gdf_append.columns: gdf_append["elevation"] = gdf_append[elev_col].astype(float) break - - keep_cols = ["geometry", "elevation"] if "elevation" in gdf_append.columns else ["geometry"] + + keep_cols = ( + ["geometry", "elevation"] if "elevation" in gdf_append.columns else ["geometry"] + ) gdf_append = gdf_append[keep_cols] - + # 기존 gpkg 파일에 이어쓰기 (append mode, pyogrio 엔진 사용) print(" -> GeoPackage에 데이터 이어붙이는 중...") - gdf_append.to_file(gpkg_output_path, layer="contours", driver="GPKG", mode="a", spatial_index=True, engine="pyogrio") + gdf_append.to_file( + gpkg_output_path, + layer="contours", + driver="GPKG", + mode="a", + spatial_index=True, + engine="pyogrio", + ) print(f" -> 완료 (레코드 수: {len(gdf_append)}개)") - + except Exception as e: print(f" -> [에러] {shp_path.name} 파일 처리 중 오류 발생: {e}. 계속 진행합니다.") continue - + end_time = time.time() elapsed = end_time - start_time print("\n==================================================") @@ -92,5 +108,6 @@ def convert_shp_to_gpkg(): print(f"★ 파일 위치: {gpkg_output_path}") print("==================================================") + if __name__ == "__main__": convert_shp_to_gpkg() diff --git a/resources/knowledge/original/_pipeline/build_srcmap.py b/resources/knowledge/original/_pipeline/build_srcmap.py index 8b8dd121..f7e34d86 100644 --- a/resources/knowledge/original/_pipeline/build_srcmap.py +++ b/resources/knowledge/original/_pipeline/build_srcmap.py @@ -1,69 +1,86 @@ # -*- coding: utf-8 -*- """lawapi.json에서 정확명 매칭 행을 뽑아 시행일/개정일 맵 생성 + 수동 보정.""" + import json from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + OUT = Path(str(DATA_DIR)) api = json.load(open(OUT / "lawapi.json", encoding="utf-8")) # 목록상 명칭 -> API 조회명 (다르면 매핑) Q = { - "산림자원의 조성 및 관리에 관한 법률": "산림자원의 조성 및 관리에 관한 법률", - "산림자원의 조성 및 관리에 관한 법률 시행령": "산림자원의 조성 및 관리에 관한 법률 시행령", - "산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙", - "산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률", - "산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령", - "산림보호법": "산림보호법", "산지관리법": "산지관리법", - "자연환경보전법": "자연환경보전법", "자연재해대책법": "자연재해대책법", - "환경영향평가법": "환경영향평가법", - "보조금 관리에 관한 법률": "보조금 관리에 관한 법률", - "국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률", - "국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령", - "지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률", - "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", - "도로법": "도로법", "농어촌도로정비법": "농어촌도로 정비법", - "국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률", - "도로명주소법": "도로명주소법", "도로명주소법 시행령": "도로명주소법 시행령", - "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", - "산업안전보건법": "산업안전보건법", "산업재해보상보험법": "산업재해보상보험법", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", - "고용보험법 시행령": "고용보험법 시행령", - "국민건강보험법": "국민건강보험법", "국민연금법": "국민연금법", - "노인장기요양보험법": "노인장기요양보험법", "노인장기요양보험법 시행령": "노인장기요양보험법 시행령", - "부가가치세법": "부가가치세법", "근로기준법": "근로기준법", - "산업표준화법": "산업표준화법", "전자서명법": "전자서명법", - "공동주택관리법": "공동주택관리법", - "임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정", - "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정", - "사업종류별 산재보험료율 고시": "사업종류별 산재보험료율", - "건설업 산업안전보건관리비 계상 및 사용기준": "건설업 산업안전보건관리비 계상 및 사용기준", - "(국토교통부) 사회보험의 보험료 적용기준": "사회보험의 보험료 적용기준", - "엔지니어링사업대가의 기준": "엔지니어링사업대가의 기준", + "산림자원의 조성 및 관리에 관한 법률": "산림자원의 조성 및 관리에 관한 법률", + "산림자원의 조성 및 관리에 관한 법률 시행령": "산림자원의 조성 및 관리에 관한 법률 시행령", + "산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙", + "산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률", + "산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령", + "산림보호법": "산림보호법", + "산지관리법": "산지관리법", + "자연환경보전법": "자연환경보전법", + "자연재해대책법": "자연재해대책법", + "환경영향평가법": "환경영향평가법", + "보조금 관리에 관한 법률": "보조금 관리에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령", + "지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률", + "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", + "도로법": "도로법", + "농어촌도로정비법": "농어촌도로 정비법", + "국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률", + "도로명주소법": "도로명주소법", + "도로명주소법 시행령": "도로명주소법 시행령", + "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", + "산업안전보건법": "산업안전보건법", + "산업재해보상보험법": "산업재해보상보험법", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", + "고용보험법 시행령": "고용보험법 시행령", + "국민건강보험법": "국민건강보험법", + "국민연금법": "국민연금법", + "노인장기요양보험법": "노인장기요양보험법", + "노인장기요양보험법 시행령": "노인장기요양보험법 시행령", + "부가가치세법": "부가가치세법", + "근로기준법": "근로기준법", + "산업표준화법": "산업표준화법", + "전자서명법": "전자서명법", + "공동주택관리법": "공동주택관리법", + "임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정", + "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정", + "사업종류별 산재보험료율 고시": "사업종류별 산재보험료율", + "건설업 산업안전보건관리비 계상 및 사용기준": "건설업 산업안전보건관리비 계상 및 사용기준", + "(국토교통부) 사회보험의 보험료 적용기준": "사회보험의 보험료 적용기준", + "엔지니어링사업대가의 기준": "엔지니어링사업대가의 기준", } # 조회명이 목록명과 다른 경우 실제 API 반환명 지정 EXACT = { - "농어촌도로정비법": "농어촌도로 정비법", - "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", - "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령ㆍ예규 등의 발령 및 관리에 관한 규정", - "사업종류별 산재보험료율 고시": "2026년도 사업종류별 산재보험료율", - "(국토교통부) 사회보험의 보험료 적용기준": "(국토교통부) 사회보험의 보험료 적용기준", + "농어촌도로정비법": "농어촌도로 정비법", + "측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률", + "훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령ㆍ예규 등의 발령 및 관리에 관한 규정", + "사업종류별 산재보험료율 고시": "2026년도 사업종류별 산재보험료율", + "(국토교통부) 사회보험의 보험료 적용기준": "(국토교통부) 사회보험의 보험료 적용기준", } SRC_LAW = "국가법령정보센터" @@ -71,61 +88,226 @@ m = {} for name, q in Q.items(): rows = api.get(q, {}).get("rows", []) want = EXACT.get(name, name) - hit = next((r for r in rows if r["명"] == want), None) or next((r for r in rows if r["명"].startswith(want)), None) + hit = next((r for r in rows if r["명"] == want), None) or next( + (r for r in rows if r["명"].startswith(want)), None + ) if not hit: print("!! 미매칭", name, "|", [r["명"] for r in rows][:3]) continue - m[name] = {"시행": hit["시행"], "개정": hit["공포"], "구분": hit["제개정"], - "호수": hit["번호"], "종류": hit["종류"], "출처": SRC_LAW, "실명": hit["명"]} + m[name] = { + "시행": hit["시행"], + "개정": hit["공포"], + "구분": hit["제개정"], + "호수": hit["번호"], + "종류": hit["종류"], + "출처": SRC_LAW, + "실명": hit["명"], + } # ── 2·3차 조회 결과 및 별표·비수록 항목 수동 등록 ── MANUAL = { - "국가균형발전특별법": ("2026.07.01", "2026.03.05", "일부개정", "21447", "법률", SRC_LAW, - "폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」"), - "수치지도 작성 작업규칙": ("2015.06.04", "2015.06.04", "일부개정", "00209", "국토교통부령", SRC_LAW, ""), - "재난구호 및 재난복구 비용 부담기준 등에 관한 규정": ("2025.11.28", "2025.11.27", "일부개정", "35875", "대통령령", SRC_LAW, - "「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)"), - "임도 품셈 적용기준 / 임도표준품셈": ("2026.01.01", "2025.11.26", "전부개정", "2025-82", "고시", SRC_LAW, - "현 「산림사업 표준품셈」(산림청 고시)"), - "건설공사 감독자 업무 지침": ("2026.07.08", "2026.07.08", "일부개정", "2026-360", "고시", SRC_LAW, - "현 「건설공사 사업관리방식 검토기준 및 업무수행지침」"), - "공사장의 비산분진 발생원 시설관리기준": ("2026.07.15", "2026.07.15", "일부개정", "00049", "환경부령", SRC_LAW, - "「대기환경보전법 시행규칙」 별표에 수록"), - "국가지점번호판 규격 등 고시": ("2024.07.05", "2024.07.05", "제정", "2024-56", "고시", SRC_LAW, - "「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」"), - "국가지점번호 부여기준 및 방법": ("2012.12.18", "2012.12.12", "제정", "2012-55", "고시", SRC_LAW, - "「국가지점번호 기준점 고시」"), - "입찰유의서(계약예규)": ("2025.12.31", "2025.12.31", "일부개정", "", "계약예규", SRC_LAW, - "「(계약예규) 공사입찰유의서」"), - "콘크리트 표준시방서": ("2025.01.05", "2024.12.30", "일부개정", "2025-879", "고시", "국가건설기준센터", - "KCS 14 20 00 (국토교통부 고시로도 수록)"), - "도로공사 표준시방서": ("2023.01.12", "2023.01.06", "일부개정", "2023-907", "고시", "국가건설기준센터", - "KCS 44 00 00"), - "토목공사 표준시방서": ("2023.01.25", "2023.01.19", "일부개정", "2023-48", "고시", "국가건설기준센터", - "현 KCS 10 00 00 공통공사 표준시방서로 재편"), - "건설공사 표준시방서": ("2018.08.09", "2018.08.03", "제정", "2018-468", "고시", "국가건설기준센터", - "건설기준 코드(KDS/KCS) 체계로 통합"), - "건설공사 비탈면 표준시방서": ("", "", "", "", "", "국가건설기준센터", - "KCS 11 70 00 비탈면 (법령정보센터 미수록)"), - "임도시설공사 표준시방서": ("", "", "", "", "", "산림청", - "법령정보센터·건설기준센터 모두 미수록"), - "산림관리기반시설의 설계 및 시설기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW, - "「산림자원법 시행규칙」 별표2에 수록"), - "산림관리기반시설의 범위 및 기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW, - "「산림자원법 시행규칙」 별표1에 수록"), - "산림관리기반시설의 타당성평가 항목별 기준 및 방법": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW, - "「산림자원법 시행규칙」 별표1의2에 수록"), - "지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": ("", "", "", "", "지침", "산림청", - "법령정보센터 미수록"), - "중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"), - "예산편성기준": ("", "", "", "", "지침", "기획재정부", - "「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포"), - "토목공사원가계산 제비율 적용기준": ("", "", "", "", "기준", "조달청", - "법령정보센터 미수록, 조달청 연간 발표"), + "국가균형발전특별법": ( + "2026.07.01", + "2026.03.05", + "일부개정", + "21447", + "법률", + SRC_LAW, + "폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」", + ), + "수치지도 작성 작업규칙": ( + "2015.06.04", + "2015.06.04", + "일부개정", + "00209", + "국토교통부령", + SRC_LAW, + "", + ), + "재난구호 및 재난복구 비용 부담기준 등에 관한 규정": ( + "2025.11.28", + "2025.11.27", + "일부개정", + "35875", + "대통령령", + SRC_LAW, + "「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)", + ), + "임도 품셈 적용기준 / 임도표준품셈": ( + "2026.01.01", + "2025.11.26", + "전부개정", + "2025-82", + "고시", + SRC_LAW, + "현 「산림사업 표준품셈」(산림청 고시)", + ), + "건설공사 감독자 업무 지침": ( + "2026.07.08", + "2026.07.08", + "일부개정", + "2026-360", + "고시", + SRC_LAW, + "현 「건설공사 사업관리방식 검토기준 및 업무수행지침」", + ), + "공사장의 비산분진 발생원 시설관리기준": ( + "2026.07.15", + "2026.07.15", + "일부개정", + "00049", + "환경부령", + SRC_LAW, + "「대기환경보전법 시행규칙」 별표에 수록", + ), + "국가지점번호판 규격 등 고시": ( + "2024.07.05", + "2024.07.05", + "제정", + "2024-56", + "고시", + SRC_LAW, + "「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」", + ), + "국가지점번호 부여기준 및 방법": ( + "2012.12.18", + "2012.12.12", + "제정", + "2012-55", + "고시", + SRC_LAW, + "「국가지점번호 기준점 고시」", + ), + "입찰유의서(계약예규)": ( + "2025.12.31", + "2025.12.31", + "일부개정", + "", + "계약예규", + SRC_LAW, + "「(계약예규) 공사입찰유의서」", + ), + "콘크리트 표준시방서": ( + "2025.01.05", + "2024.12.30", + "일부개정", + "2025-879", + "고시", + "국가건설기준센터", + "KCS 14 20 00 (국토교통부 고시로도 수록)", + ), + "도로공사 표준시방서": ( + "2023.01.12", + "2023.01.06", + "일부개정", + "2023-907", + "고시", + "국가건설기준센터", + "KCS 44 00 00", + ), + "토목공사 표준시방서": ( + "2023.01.25", + "2023.01.19", + "일부개정", + "2023-48", + "고시", + "국가건설기준센터", + "현 KCS 10 00 00 공통공사 표준시방서로 재편", + ), + "건설공사 표준시방서": ( + "2018.08.09", + "2018.08.03", + "제정", + "2018-468", + "고시", + "국가건설기준센터", + "건설기준 코드(KDS/KCS) 체계로 통합", + ), + "건설공사 비탈면 표준시방서": ( + "", + "", + "", + "", + "", + "국가건설기준센터", + "KCS 11 70 00 비탈면 (법령정보센터 미수록)", + ), + "임도시설공사 표준시방서": ( + "", + "", + "", + "", + "", + "산림청", + "법령정보센터·건설기준센터 모두 미수록", + ), + "산림관리기반시설의 설계 및 시설기준": ( + "2026.02.01", + "2026.02.01", + "", + "", + "별표", + SRC_LAW, + "「산림자원법 시행규칙」 별표2에 수록", + ), + "산림관리기반시설의 범위 및 기준": ( + "2026.02.01", + "2026.02.01", + "", + "", + "별표", + SRC_LAW, + "「산림자원법 시행규칙」 별표1에 수록", + ), + "산림관리기반시설의 타당성평가 항목별 기준 및 방법": ( + "2026.02.01", + "2026.02.01", + "", + "", + "별표", + SRC_LAW, + "「산림자원법 시행규칙」 별표1의2에 수록", + ), + "지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": ( + "", + "", + "", + "", + "지침", + "산림청", + "법령정보센터 미수록", + ), + "중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"), + "예산편성기준": ( + "", + "", + "", + "", + "지침", + "기획재정부", + "「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포", + ), + "토목공사원가계산 제비율 적용기준": ( + "", + "", + "", + "", + "기준", + "조달청", + "법령정보센터 미수록, 조달청 연간 발표", + ), } for k, v in MANUAL.items(): - m[k] = {"시행": v[0], "개정": v[1], "구분": v[2], "호수": v[3], - "종류": v[4], "출처": v[5], "실명": "", "추가": v[6]} + m[k] = { + "시행": v[0], + "개정": v[1], + "구분": v[2], + "호수": v[3], + "종류": v[4], + "출처": v[5], + "실명": "", + "추가": v[6], + } json.dump(m, open(OUT / "srcmap.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) print(f"매핑 {len(m)}건 생성") diff --git a/resources/knowledge/original/_pipeline/check_law.py b/resources/knowledge/original/_pipeline/check_law.py index 3248283e..7029f246 100644 --- a/resources/knowledge/original/_pipeline/check_law.py +++ b/resources/knowledge/original/_pipeline/check_law.py @@ -1,30 +1,42 @@ # -*- coding: utf-8 -*- """국가법령정보센터 Open API로 목록 항목의 보유 여부·시행일·개정일 조회.""" + import json, re, time, urllib.parse, urllib.request from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + import xml.etree.ElementTree as ET OUT = Path(str(DATA_DIR)) + def api(target, query, display=5): - url = ("https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML" - f"&target={target}&display={display}&query=" + urllib.parse.quote(query)) + url = ( + "https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML" + f"&target={target}&display={display}&query=" + urllib.parse.quote(query) + ) for _ in range(3): try: with urllib.request.urlopen(url, timeout=25) as r: @@ -33,6 +45,7 @@ def api(target, query, display=5): time.sleep(1.2) return None + def txt(node, *names): for n in names: el = node.find(n) @@ -40,51 +53,75 @@ def txt(node, *names): return el.text.strip() return "" + def ymd(s): s = re.sub(r"\D", "", s or "") return f"{s[:4]}.{s[4:6]}.{s[6:8]}" if len(s) == 8 else "" + LAWS = [ - "산림자원의 조성 및 관리에 관한 법률", "산림자원의 조성 및 관리에 관한 법률 시행령", - "산림자원의 조성 및 관리에 관한 법률 시행규칙", "산림기술 진흥 및 관리에 관한 법률", - "산림기술 진흥 및 관리에 관한 법률 시행령", "산림보호법", "산지관리법", - "자연환경보전법", "자연재해대책법", "환경영향평가법", "국가균형발전특별법", - "보조금 관리에 관한 법률", "국가를 당사자로 하는 계약에 관한 법률", - "국가를 당사자로 하는 계약에 관한 법률 시행령", - "지방자치단체를 당사자로 하는 계약에 관한 법률", - "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", - "도로법", "농어촌도로 정비법", "국토의 계획 및 이용에 관한 법률", - "도로명주소법", "도로명주소법 시행령", "공간정보의 구축 및 관리 등에 관한 법률", - "산업안전보건법", "산업재해보상보험법", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", - "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", - "고용보험법 시행령", "국민건강보험법", "국민연금법", "노인장기요양보험법", - "노인장기요양보험법 시행령", "부가가치세법", "근로기준법", "산업표준화법", - "전자서명법", "공동주택관리법", + "산림자원의 조성 및 관리에 관한 법률", + "산림자원의 조성 및 관리에 관한 법률 시행령", + "산림자원의 조성 및 관리에 관한 법률 시행규칙", + "산림기술 진흥 및 관리에 관한 법률", + "산림기술 진흥 및 관리에 관한 법률 시행령", + "산림보호법", + "산지관리법", + "자연환경보전법", + "자연재해대책법", + "환경영향평가법", + "국가균형발전특별법", + "보조금 관리에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률", + "국가를 당사자로 하는 계약에 관한 법률 시행령", + "지방자치단체를 당사자로 하는 계약에 관한 법률", + "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령", + "도로법", + "농어촌도로 정비법", + "국토의 계획 및 이용에 관한 법률", + "도로명주소법", + "도로명주소법 시행령", + "공간정보의 구축 및 관리 등에 관한 법률", + "산업안전보건법", + "산업재해보상보험법", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률", + "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령", + "고용보험법 시행령", + "국민건강보험법", + "국민연금법", + "노인장기요양보험법", + "노인장기요양보험법 시행령", + "부가가치세법", + "근로기준법", + "산업표준화법", + "전자서명법", + "공동주택관리법", ] RULES = [ - "임도설치 및 관리 등에 관한 규정", - "산림관리기반시설의 설계 및 시설기준", - "산림관리기반시설의 범위 및 기준", - "산림관리기반시설의 타당성평가", - "임도 품셈", - "자연휴양림업무 처리지침", - "중기운용관리", - "훈령·예규 등의 발령 및 관리에 관한 규정", - "국가지점번호", - "재난구호 및 재난복구 비용 부담기준 등에 관한 규정", - "사업종류별 산재보험료율", - "건설업 산업안전보건관리비 계상 및 사용기준", - "사회보험의 보험료 적용기준", - "엔지니어링사업대가의 기준", - "예산안 편성", - "토목공사원가계산 제비율", - "입찰유의서", - "수치지도 작성 작업규칙", - "비산분진 발생원 시설관리기준", - "건설공사 감독자 업무 지침", - "콘크리트 표준시방서", "도로공사 표준시방서", "임도시설공사 표준시방서", + "임도설치 및 관리 등에 관한 규정", + "산림관리기반시설의 설계 및 시설기준", + "산림관리기반시설의 범위 및 기준", + "산림관리기반시설의 타당성평가", + "임도 품셈", + "자연휴양림업무 처리지침", + "중기운용관리", + "훈령·예규 등의 발령 및 관리에 관한 규정", + "국가지점번호", + "재난구호 및 재난복구 비용 부담기준 등에 관한 규정", + "사업종류별 산재보험료율", + "건설업 산업안전보건관리비 계상 및 사용기준", + "사회보험의 보험료 적용기준", + "엔지니어링사업대가의 기준", + "예산안 편성", + "토목공사원가계산 제비율", + "입찰유의서", + "수치지도 작성 작업규칙", + "비산분진 발생원 시설관리기준", + "건설공사 감독자 업무 지침", + "콘크리트 표준시방서", + "도로공사 표준시방서", + "임도시설공사 표준시방서", ] res = {} @@ -94,18 +131,23 @@ for grp, target, items in (("법령", "law", LAWS), ("행정규칙", "admrul", R rows = [] if root is not None: for node in root.findall("law") + root.findall("admrul"): - rows.append({ - "명": txt(node, "법령명한글", "행정규칙명"), - "약칭": txt(node, "법령약칭명"), - "종류": txt(node, "법령구분명", "행정규칙종류"), - "부처": txt(node, "소관부처명", "소관부처명"), - "공포": ymd(txt(node, "공포일자", "발령일자")), - "시행": ymd(txt(node, "시행일자")), - "제개정": txt(node, "제개정구분명", "제개정구분코드"), - "번호": txt(node, "공포번호", "발령번호"), - }) + rows.append( + { + "명": txt(node, "법령명한글", "행정규칙명"), + "약칭": txt(node, "법령약칭명"), + "종류": txt(node, "법령구분명", "행정규칙종류"), + "부처": txt(node, "소관부처명", "소관부처명"), + "공포": ymd(txt(node, "공포일자", "발령일자")), + "시행": ymd(txt(node, "시행일자")), + "제개정": txt(node, "제개정구분명", "제개정구분코드"), + "번호": txt(node, "공포번호", "발령번호"), + } + ) res[q] = {"grp": grp, "target": target, "rows": rows} - print(f"[{grp}] {q} -> {len(rows)}건" + (f" | {rows[0]['명']} 시행 {rows[0]['시행']}" if rows else "")) + print( + f"[{grp}] {q} -> {len(rows)}건" + + (f" | {rows[0]['명']} 시행 {rows[0]['시행']}" if rows else "") + ) time.sleep(0.35) json.dump(res, open(OUT / "lawapi.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) diff --git a/resources/knowledge/original/_pipeline/collect_cost_sources.py b/resources/knowledge/original/_pipeline/collect_cost_sources.py index 5dbe69fa..c5163019 100644 --- a/resources/knowledge/original/_pipeline/collect_cost_sources.py +++ b/resources/knowledge/original/_pipeline/collect_cost_sources.py @@ -19,6 +19,7 @@ 값형(4~6)은 원본 스냅샷을 폴더에 보존하고, 프로그램용 데이터셋은 여기서 별도 추출해 구성한다 (2026-08-14 사용자 결정). 표준시장단가는 수집 제외 (100억 미만 공사 미적용·품셈 방식과 별도 트랙). """ + import csv import json import os @@ -36,18 +37,30 @@ UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} # ── 반기 갱신 대상 URL (2026 상반기/2026년판 기준) ── DOC_SOURCES = [ # (폴더, 파일명, URL, referer) - ("노임단가_건설업_대한건설협회", "2026상반기_건설업_임금실태조사_대한건설협회.pdf", - "https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf", - "https://www.cak.or.kr/lay1/S1T16C41/sublink.do"), - ("노임단가_제조업_중소기업중앙회", "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf", - None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용 - "https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372"), - ("건설공사_표준품셈", "2026년_건설공사_표준품셈.pdf", - "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1", - "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"), - ("건설공사_표준품셈", "2026년_건설공사_표준품셈_개정사항.pdf", - "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2", - "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"), + ( + "노임단가_건설업_대한건설협회", + "2026상반기_건설업_임금실태조사_대한건설협회.pdf", + "https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf", + "https://www.cak.or.kr/lay1/S1T16C41/sublink.do", + ), + ( + "노임단가_제조업_중소기업중앙회", + "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf", + None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용 + "https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372", + ), + ( + "건설공사_표준품셈", + "2026년_건설공사_표준품셈.pdf", + "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1", + "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1", + ), + ( + "건설공사_표준품셈", + "2026년_건설공사_표준품셈_개정사항.pdf", + "https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2", + "https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1", + ), ] G2B_OPS = [ @@ -93,7 +106,11 @@ def collect_docs(): continue url = "https://www.kbiz.or.kr" + links[0].replace("&", "&") data = fetch(url, referer) - if not data[:4] == b"%PDF" and not data[:4] == b"PK\x03\x04" and not data[:8].startswith(b"\xd0\xcf\x11\xe0"): + if ( + not data[:4] == b"%PDF" + and not data[:4] == b"PK\x03\x04" + and not data[:8].startswith(b"\xd0\xcf\x11\xe0") + ): print(f"FAIL {fname}: PDF/HWP 아님 ({data[:8].hex()}) — URL 갱신 필요") continue dest.write_bytes(data) @@ -109,13 +126,22 @@ def collect_values(): ecos = read_key(r"ECOS[^`]*\n- 인증키: `([^`]+)`", "ECOS") year = today[:4] rows = [] - for code, name in [("0000001", "원/미국달러"), ("0000002", "원/일본엔100"), - ("0000003", "원/유로"), ("0000012", "원/영국파운드"), ("0000053", "원/위안")]: - url = (f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/" - f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}") + for code, name in [ + ("0000001", "원/미국달러"), + ("0000002", "원/일본엔100"), + ("0000003", "원/유로"), + ("0000012", "원/영국파운드"), + ("0000053", "원/위안"), + ]: + url = ( + f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/" + f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}" + ) j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace")) for r in j.get("StatisticSearch", {}).get("row", []): - rows.append({"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]}) + rows.append( + {"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]} + ) out = BASE / "환율_한국은행ECOS" / f"환율_일별_{year}0101_{today}.csv" out.parent.mkdir(exist_ok=True) with open(out, "w", newline="", encoding="utf-8-sig") as f: @@ -127,11 +153,18 @@ def collect_values(): # 유가: 오늘 전국 평균 + 유종별 최근 7일 opinet = read_key(r"오피넷[^`]*`([^`]+)`", "오피넷") data = {"수집일": today} - j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60)) + j = json.loads( + fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60) + ) data["전국평균"] = j.get("RESULT", {}).get("OIL", []) data["최근7일"] = {} for prod in ["B027", "D047"]: # 휘발유, 자동차용경유 - j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}", timeout=60)) + j = json.loads( + fetch( + f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}", + timeout=60, + ) + ) data["최근7일"][prod] = j.get("RESULT", {}).get("OIL", []) out = BASE / "유가_오피넷" / f"유가_전국평균_{today}.json" out.parent.mkdir(exist_ok=True) @@ -146,8 +179,10 @@ def collect_snapshot(outdir=None): for op, label in G2B_OPS: page, got, total = 1, 0, 1 while got < total: - url = (f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}" - f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json") + url = ( + f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}" + f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json" + ) j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace")) body = j.get("response", {}).get("body", {}) total = int(body.get("totalCount") or 0) @@ -173,7 +208,8 @@ def collect_snapshot(outdir=None): w.writeheader() w.writerows(rows) (outp / f"나라장터_시설공통자재_{today}.json").write_text( - json.dumps(rows, ensure_ascii=False), encoding="utf-8") + json.dumps(rows, ensure_ascii=False), encoding="utf-8" + ) print(f"saved {out.name} ({len(rows):,} rows, +json)") diff --git a/resources/knowledge/original/_pipeline/extract_zip.py b/resources/knowledge/original/_pipeline/extract_zip.py index 43aa0b6f..934af6d7 100644 --- a/resources/knowledge/original/_pipeline/extract_zip.py +++ b/resources/knowledge/original/_pipeline/extract_zip.py @@ -4,6 +4,7 @@ - zip 내 CP949(EUC-KR) 파일명 mojibake를 복원해 `첨부/[zip]<이름>/` 에 해제 - 내부 HWP/HWPX → md 변환(hwpx_text.to_md / hwp5_to_md) """ + import re, sys, zipfile from pathlib import Path @@ -12,6 +13,7 @@ import hwpx_text ROOT = Path(__file__).resolve().parent.parent + def fixname(n): """zip 엔트리명 CP437 mojibake → CP949 복원.""" try: @@ -19,9 +21,11 @@ def fixname(n): except Exception: return n + def safe_part(s): return re.sub(r'[:*?"<>|]', "_", s).strip() + def extract_one(zip_path): zf = zipfile.ZipFile(zip_path) dest = zip_path.parent / ("[압축] " + zip_path.stem) @@ -38,6 +42,7 @@ def extract_one(zip_path): n += 1 return dest, n + def convert_dir(folder): ok = fail = 0 for f in sorted(folder.rglob("*")): @@ -46,9 +51,9 @@ def convert_dir(folder): md = f.with_suffix(".md") try: b = f.read_bytes()[:4] - if b[:2] == b"PK": # HWPX + if b[:2] == b"PK": # HWPX text = hwpx_text.to_md(f) - elif b.hex() == "d0cf11e0": # 구형 HWP + elif b.hex() == "d0cf11e0": # 구형 HWP text = hwpx_text.hwp5_to_md(f) else: fail += 1 @@ -60,6 +65,7 @@ def convert_dir(folder): fail += 1 return ok, fail + if __name__ == "__main__": zips = [Path(a) for a in sys.argv[1:]] or list(ROOT.rglob("첨부/*.zip")) for z in zips: diff --git a/resources/knowledge/original/_pipeline/fix_box_tables.py b/resources/knowledge/original/_pipeline/fix_box_tables.py index d10cc2d0..090b7a81 100644 --- a/resources/knowledge/original/_pipeline/fix_box_tables.py +++ b/resources/knowledge/original/_pipeline/fix_box_tables.py @@ -4,28 +4,34 @@ 법령 조문의 안에 있던 박스 드로잉 표가 이미지 로컬화 후 텍스트로 남는데, │로 열을 구분하므로 md 표로 복원한다. 각 표에는 대응 ![그림]도 이미 있다. """ + import re, sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent BORDER = set("┌┬┐├┼┤└┴┘─━┏┳┓┣╋┫┗┻┛│┃ \t") VBAR = "│┃|" -QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> ' +QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> ' + def unq(l): return QP.sub("", l) + def is_border(l): s = unq(l).strip() return bool(s) and all(c in BORDER for c in s) and any(c in "─━┼┬┴┌┐└┘├┤" for c in s) + def is_data(l): return any(c in "│┃" for c in unq(l)) + def split_cells(l): s = unq(l).strip().strip("│┃") return [c.strip() for c in re.split(r"[│┃]", s)] + def convert_block(lines): rows = [split_cells(l) for l in lines if is_data(l)] rows = [r for r in rows if any(c for c in r)] @@ -36,12 +42,12 @@ def convert_block(lines): return None rows = [r + [""] * (w - len(r)) for r in rows] esc = lambda c: c.replace("|", "\\|") - out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", - "|" + "|".join(["---"] * w) + "|"] + out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"] for r in rows[1:]: out.append("| " + " | ".join(esc(c) for c in r) + " |") return out + def fix(md_path): lines = md_path.read_text(encoding="utf-8").split("\n") out = [] @@ -51,14 +57,20 @@ def fix(md_path): while i < n: if lines[i].lstrip().startswith("```"): infence = not infence - out.append(lines[i]); i += 1 + out.append(lines[i]) + i += 1 continue # 박스표 블록 시작: border 또는 data(│ 포함) 연속 (펜스 밖에서만) - if not infence and (is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|"))): + if not infence and ( + is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|")) + ): j = i block = [] - while j < n and (is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|"))): - block.append(lines[j]); j += 1 + while j < n and ( + is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|")) + ): + block.append(lines[j]) + j += 1 data_rows = [b for b in block if is_data(b)] md = convert_block(block) # 열이 일정한 진짜 표만 md 표로. 아니면(수식 등) 코드펜스로 정렬 보존. @@ -75,11 +87,13 @@ def fix(md_path): changed += 1 i = j continue - out.append(lines[i]); i += 1 + out.append(lines[i]) + i += 1 if changed: md_path.write_text("\n".join(out), encoding="utf-8") return changed + if __name__ == "__main__": total = 0 for md in ROOT.rglob("*.md"): diff --git a/resources/knowledge/original/_pipeline/fix_law_images.py b/resources/knowledge/original/_pipeline/fix_law_images.py index 0bea69f3..b6b520ca 100644 --- a/resources/knowledge/original/_pipeline/fix_law_images.py +++ b/resources/knowledge/original/_pipeline/fix_law_images.py @@ -4,17 +4,25 @@ 법령 XML 조문·개정문에 인라인으로 박힌 수식·그림 이미지 처리. 이미지는 각 명칭 폴더의 pic/ 에 저장하고 md에서 ../pic 상대참조. """ + import re, sys, time, urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent UA = {"User-Agent": "Mozilla/5.0"} # src="URL" 형식과 id="flSeq" 형식(내부 이미지) 모두 처리 -IMG = re.compile(r']*?)/?>') +IMG = re.compile(r"]*?)/?>") SRC = re.compile(r'src="([^"]+)"') IID = re.compile(r'id="(\d+)"') -EXT = {b"\x89PNG": ".png", b"\xff\xd8\xff": ".jpg", b"GIF8": ".gif", - b"BM": ".bmp", b"II*\x00": ".tif", b"MM\x00*": ".tif"} +EXT = { + b"\x89PNG": ".png", + b"\xff\xd8\xff": ".jpg", + b"GIF8": ".gif", + b"BM": ".bmp", + b"II*\x00": ".tif", + b"MM\x00*": ".tif", +} + def sniff(b): for sig, ext in EXT.items(): @@ -22,6 +30,7 @@ def sniff(b): return ext return ".png" + def download(url): u = url.replace("http://", "https://") for _ in range(3): @@ -34,6 +43,7 @@ def download(url): time.sleep(1.5) return None + def fix(md_path): """md 파일: → pic/ 저장 + ![그림](../pic/..) 참조. 폴더는 명칭 폴더의 pic/.""" text = md_path.read_text(encoding="utf-8") @@ -43,6 +53,7 @@ def fix(md_path): folder = md_path.parent picdir = folder / "pic" seq = [0] + def repl(m): attrs = m.group(1) ms = SRC.search(attrs) @@ -61,16 +72,22 @@ def fix(md_path): fn = f"{md_path.stem}_img{seq[0]}{sniff(blob)}" (picdir / fn).write_bytes(blob) return f"![그림]()" + new = IMG.sub(repl, text) # 부칙 등에서 여는 태그와 분리돼 남은 고아 닫는 태그 제거(단독 줄/인용줄 포함) - new = re.sub(r'^>?\s*\s*$', ">", new, flags=re.M) + new = re.sub(r"^>?\s*\s*$", ">", new, flags=re.M) new = new.replace("", "") if new != text: md_path.write_text(new, encoding="utf-8") return seq[0] + if __name__ == "__main__": - mds = list(ROOT.rglob("현행_*.md")) + list(ROOT.rglob("교본시점_*.md")) + list(ROOT.rglob("CHANGELOG.md")) + mds = ( + list(ROOT.rglob("현행_*.md")) + + list(ROOT.rglob("교본시점_*.md")) + + list(ROOT.rglob("CHANGELOG.md")) + ) mds = [m for m in mds if "임도기술교본" not in str(m) and "_pipeline" not in str(m)] tot = 0 for md in sorted(mds): diff --git a/resources/knowledge/original/_pipeline/fix_spacing.py b/resources/knowledge/original/_pipeline/fix_spacing.py index 4204d0ab..ed0f1842 100644 --- a/resources/knowledge/original/_pipeline/fix_spacing.py +++ b/resources/knowledge/original/_pipeline/fix_spacing.py @@ -7,6 +7,7 @@ PDF 표(정상)는 그대로 두고, 공백이 붙어버린 프로즈 줄만 HWP - 별표: 같은 폴더 현행 XML의 별표서식파일링크(HWP)로 원본을 받아 hwp5 추출 - 첨부: 같은 폴더의 .hwp/.hwpx 원본을 사용 """ + import re, sys, time, urllib.request import xml.etree.ElementTree as ET from pathlib import Path @@ -18,11 +19,13 @@ import hwp5_text BASEURL = "https://www.law.go.kr" UA = {"User-Agent": "Mozilla/5.0"} + def nsp(s): # 매칭 키: 공백·특수문자(사설글리프·불릿·문장부호) 제거 → 한글/영숫자만. # HWP와 PDF 추출의 글자 차이(ㅇ·ㆍ·U+F09E 등)를 흡수한다. return re.sub(r"[^가-힣0-9A-Za-z]", "", s) + def spaced_index(hwp_path): """HWP 전체를 하나의 띄어쓰기 문자열로 잇고, 무공백↔원문 위치 맵을 만든다. @@ -52,6 +55,7 @@ def respace(body, spaced, spaced_nsp, pos): # 원문 줄바꿈(문단경계)은 공백으로 return re.sub(r"\s+", " ", seg).strip() + def download(link, dest): url = link if link.startswith("http") else BASEURL + link for _ in range(3): @@ -65,6 +69,7 @@ def download(link, dest): time.sleep(1.5) return False + def byl_link_map(folder): """현행 XML → {별표 stem 접두: HWP링크}. md 파일명과 매칭용.""" xmls = sorted(folder.parent.glob("현행_*.xml")) @@ -83,16 +88,23 @@ def byl_link_map(folder): kind = (b.findtext("별표구분") or "별표").strip() # 파일명 접두(별표/서식/별지)와 XML 구분을 맞춘다 pre = "별표" if kind == "별표" else ("별지" if kind == "별지" else "서식") - key = f"{pre}{num}{('의'+g) if g else ''}" + key = f"{pre}{num}{('의' + g) if g else ''}" link = b.findtext("별표서식파일링크") if link: out[key] = link return out + def restore_line(line, spaced, spaced_nsp, pos): """프로즈 줄이면 띄어쓰기 버전으로 교체. 표 행(|)은 건드리지 않는다.""" st = line.strip() - if not st or st.startswith("|") or st.startswith("#") or st.startswith(">") or st.startswith("!["): + if ( + not st + or st.startswith("|") + or st.startswith("#") + or st.startswith(">") + or st.startswith("![") + ): return line m = re.match(r"^(\s*(?:[-*]\s+|[가-힣]\.\s*|\(\d+\)\s*|\d+\.\s*)?)(.*)$", line) prefix, body = m.group(1), m.group(2) @@ -105,9 +117,10 @@ def restore_line(line, spaced, spaced_nsp, pos): return prefix + sp return line + def fix_file(md, hwp_dir_download=True): text = md.read_text(encoding="utf-8") - folder = md.parent # .../별표 또는 .../첨부 + folder = md.parent # .../별표 또는 .../첨부 stem = md.stem # 소스 HWP 확보 @@ -126,7 +139,7 @@ def fix_file(md, hwp_dir_download=True): if not hwp: return None if hwp.suffix == ".hwpx": - return None # hwpx는 별도(hwpx_text)로 이미 처리 + return None # hwpx는 별도(hwpx_text)로 이미 처리 spaced, pos = spaced_index(hwp) if not spaced: return None @@ -137,5 +150,3 @@ def fix_file(md, hwp_dir_download=True): md.write_text("\n".join(new), encoding="utf-8") return sum(1 for a, b in zip(lines, new) if a != b) return 0 - - diff --git a/resources/knowledge/original/_pipeline/get_attach.py b/resources/knowledge/original/_pipeline/get_attach.py index 37412761..523d2a06 100644 --- a/resources/knowledge/original/_pipeline/get_attach.py +++ b/resources/knowledge/original/_pipeline/get_attach.py @@ -1,31 +1,41 @@ # -*- coding: utf-8 -*- """고시·훈령 본문이 껍데기인 경우 실제 내용이 담긴 첨부파일/별표 원본(HWP)을 내려받는다.""" + import re, time, urllib.request import xml.etree.ElementTree as ET from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + ROOT = Path(str(ROOT_DIR)) UA = {"User-Agent": "Mozilla/5.0"} + def safe(s): return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".") + def get(url, tries=3): url = url.strip().replace("http://law.go.kr", "https://www.law.go.kr") if url.startswith("/"): @@ -40,6 +50,7 @@ def get(url, tries=3): return None time.sleep(2) + tot_att = tot_hwp = 0 for xml in sorted(ROOT.rglob("현행_*.xml")): folder = xml.parent @@ -62,7 +73,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")): d.mkdir(parents=True, exist_ok=True) p.write_bytes(blob) tot_att += 1 - print(f" 첨부 {len(blob)//1024:6d}KB {folder.name[:34]} / {nm[:44]}", flush=True) + print(f" 첨부 {len(blob) // 1024:6d}KB {folder.name[:34]} / {nm[:44]}", flush=True) time.sleep(0.3) # ── 2) 별표 PDF가 안내문뿐인 경우 HWP 원본 확보 ── @@ -75,17 +86,18 @@ for xml in sorted(ROOT.rglob("현행_*.xml")): continue num = (b.findtext("별표번호") or "0").lstrip("0") or "0" g = (b.findtext("별표가지번호") or "").lstrip("0") - stem = safe(f"{b.findtext('별표구분')}{num}{('의'+g) if g else ''}_{title[:48]}") + stem = safe(f"{b.findtext('별표구분')}{num}{('의' + g) if g else ''}_{title[:48]}") pdf = folder / "별표" / f"{stem}.pdf" if not pdf.exists(): continue try: import pymupdf + t = "".join(pg.get_text() for pg in pymupdf.open(pdf)) except Exception: continue if len(re.sub(r"\s", "", t)) >= 120 and "자세한 내용은" not in t: - continue # 정상 PDF + continue # 정상 PDF hlk = b.findtext("별표서식파일링크") hnm = b.findtext("별표HWP파일명") or f"{stem}.hwp" if not hlk: @@ -100,7 +112,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")): continue hp.write_bytes(blob) tot_hwp += 1 - print(f" HWP {len(blob)//1024:6d}KB {folder.name[:34]} / {stem[:44]}", flush=True) + print(f" HWP {len(blob) // 1024:6d}KB {folder.name[:34]} / {stem[:44]}", flush=True) time.sleep(0.3) print(f"\n첨부파일 {tot_att}건 / 안내문 별표의 HWP 원본 {tot_hwp}건") diff --git a/resources/knowledge/original/_pipeline/get_kcsc.py b/resources/knowledge/original/_pipeline/get_kcsc.py index ac9466ee..6b75aced 100644 --- a/resources/knowledge/original/_pipeline/get_kcsc.py +++ b/resources/knowledge/original/_pipeline/get_kcsc.py @@ -5,31 +5,40 @@ CodeList로 전체 코드를 받고, 대상 KCS 코드의 CodeViewer 본문을 표준시방서/<명칭>/KCS/<코드>_<이름>.md 로 저장한다. API Key는 keyfile(_kcsc_key.txt)에서 읽는다. """ + import json, re, sys, time, html, urllib.parse, urllib.request from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + ROOT = Path(str(ROOT_DIR / "표준시방서")) SC = Path(str(DATA_DIR)) -KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY +KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY BASE = "https://kcsc.re.kr/OpenApi" UA = {"User-Agent": "Mozilla/5.0"} + def api(path): url = f"{BASE}/{path}{'&' if '?' in path else '?'}key={KEY}" for k in range(3): @@ -42,9 +51,11 @@ def api(path): return None time.sleep(2) + def safe(s): return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".") + # ── HTML → Markdown ── def cell_text(td): t = re.sub(r"", " ", td) @@ -53,6 +64,7 @@ def cell_text(td): t = html.unescape(t) return re.sub(r"\s+", " ", t).strip() + def table_to_md(tbl): cap = "" mcap = re.search(r"]*>(.*?)", tbl, re.S) @@ -78,6 +90,7 @@ def table_to_md(tbl): out.append("| " + " | ".join(esc(c) for c in r) + " |") return "\n".join(out) + def content_to_md(c): if not c: return "" @@ -86,7 +99,7 @@ def content_to_md(c): parts = [] pos = 0 for m in re.finditer(r"", c, re.S): - pre = c[pos:m.start()] + pre = c[pos : m.start()] pt = re.sub(r"<[^>]+>", "", pre) pt = html.unescape(re.sub(r"\s+", " ", pt)).strip() if pt: @@ -105,9 +118,12 @@ def content_to_md(c): t = re.sub(r"<[^>]+>", "", t) return html.unescape(t).strip() + def viewer_to_md(doc): out = [f"# KCS {doc['code']} {doc['name']}", ""] - out.append(f"> 버전 {doc.get('version','')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode','')}") + out.append( + f"> 버전 {doc.get('version', '')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode', '')}" + ) out.append(f"> 출처: https://kcsc.re.kr/OpenApi/CodeViewer/{doc['codeType']}/{doc['code']}") out.append("") last_head = "" @@ -144,6 +160,7 @@ def viewer_to_md(doc): md.append(l) return "\n".join(md).strip() + "\n" + TARGETS = { "콘크리트 표준시방서 (KCS 14 20 00)": ["1420"], "도로공사 표준시방서 (KCS 44 00 00)": ["44"], @@ -151,23 +168,33 @@ TARGETS = { "건설공사 비탈면 표준시방서 (KCS 11 70 00)": ["117", "114030"], } + def run(): codelist = api("CodeList") if not codelist: - print("CodeList 실패"); return - (SC / "kcsc_codelist.json").write_text(json.dumps(codelist, ensure_ascii=False), encoding="utf-8") + print("CodeList 실패") + return + (SC / "kcsc_codelist.json").write_text( + json.dumps(codelist, ensure_ascii=False), encoding="utf-8" + ) kcs = [x for x in codelist if x["codeType"] == "KCS"] print(f"CodeList {len(codelist)}건 (KCS {len(kcs)})") summary = [] for folder_name, prefixes in TARGETS.items(): - codes = sorted({x["code"]: x for x in kcs - if any(x["code"].startswith(p) for p in prefixes)}.items()) + codes = sorted( + {x["code"]: x for x in kcs if any(x["code"].startswith(p) for p in prefixes)}.items() + ) folder = ROOT / safe(folder_name) / "KCS" folder.mkdir(parents=True, exist_ok=True) - idx = [f"# {folder_name} — KCS 코드 목록", "", - f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.", "", - "| 코드 | 이름 | 버전 | md |", "|---|---|---|---|"] + idx = [ + f"# {folder_name} — KCS 코드 목록", + "", + f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.", + "", + "| 코드 | 이름 | 버전 | md |", + "|---|---|---|---|", + ] ok = 0 for code, meta in codes: doc = api(f"CodeViewer/KCS/{code}") @@ -177,9 +204,9 @@ def run(): d = doc[0] fn = safe(f"{code}_{d['name']}") + ".md" (folder / fn).write_text(viewer_to_md(d), encoding="utf-8") - idx.append(f"| KCS {code} | {d['name']} | {d.get('version','')} | [{fn}](<{fn}>) |") + idx.append(f"| KCS {code} | {d['name']} | {d.get('version', '')} | [{fn}](<{fn}>) |") ok += 1 - print(f" KCS {code} {d['name'][:30]} ({len(d.get('list',[]))}절)", flush=True) + print(f" KCS {code} {d['name'][:30]} ({len(d.get('list', []))}절)", flush=True) time.sleep(0.4) (folder / "_목록.md").write_text("\n".join(idx) + "\n", encoding="utf-8") summary.append((folder_name, len(codes), ok)) @@ -189,5 +216,6 @@ def run(): for n, t, o in summary: print(f" {o}/{t} {n}") + if __name__ == "__main__": run() diff --git a/resources/knowledge/original/_pipeline/get_ks.py b/resources/knowledge/original/_pipeline/get_ks.py index c925dba5..eaac0575 100644 --- a/resources/knowledge/original/_pipeline/get_ks.py +++ b/resources/knowledge/original/_pipeline/get_ks.py @@ -1,29 +1,38 @@ # -*- coding: utf-8 -*- """e나라 표준인증에서 KS 표준 메타데이터·개정이력 수집 (원문은 DRM 열람 전용이라 미수집).""" + import json, re, time, urllib.parse, urllib.request from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + ROOT = Path(str(ROOT_DIR / "KS")) SC = Path(str(DATA_DIR)) BASE = "https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do" UA = {"User-Agent": "Mozilla/5.0"} + def fetch(ks): url = f"{BASE}?menuId=919&topMenuId=502&upperMenuId=503&ksNo={urllib.parse.quote(ks)}" for k in range(3): @@ -36,6 +45,7 @@ def fetch(ks): return None time.sleep(2) + def flatten(h): h = re.sub(r"", "", h, flags=re.S) h = re.sub(r"", "", h, flags=re.S) @@ -46,14 +56,16 @@ def flatten(h): t = re.sub(r"(\|\s*)+", "|", t) return t + def field(t, key, stop=("|",)): m = re.search(r"\|" + re.escape(key) + r"\|+([^|]*)", t) return m.group(1).strip() if m else "" + def parse(ks, h): t = flatten(h) i = t.find("|기본정보|") - seg = t[i:i + 4000] if i > 0 else t + seg = t[i : i + 4000] if i > 0 else t d = { "표준번호": field(seg, "표준번호") or ks, "표준명": field(seg, "표준명(한글)"), @@ -71,13 +83,26 @@ def parse(ks, h): hist = [] j = t.find("표준 이력사항") if j > 0: - seg2 = t[j:j + 6000] - for m in re.finditer(r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2): - hist.append({"일자": m.group(1).strip(), "구분": m.group(2).strip(), "고시번호": m.group(3).strip()}) + seg2 = t[j : j + 6000] + for m in re.finditer( + r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2 + ): + hist.append( + { + "일자": m.group(1).strip(), + "구분": m.group(2).strip(), + "고시번호": m.group(3).strip(), + } + ) d["이력"] = hist - d["상태"] = "폐지" if any(x["구분"] == "폐지" for x in hist) else ("현행" if d["표준명"] else "확인필요") + d["상태"] = ( + "폐지" + if any(x["구분"] == "폐지" for x in hist) + else ("현행" if d["표준명"] else "확인필요") + ) return d + CODES = json.load(open(SC / "ks_codes.json", encoding="utf-8")) ROOT.mkdir(parents=True, exist_ok=True) res = [] @@ -90,7 +115,10 @@ for i, ks in enumerate(CODES, 1): d = parse(ks, h) d["조회번호"] = key res.append(d) - print(f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}", flush=True) + print( + f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}", + flush=True, + ) time.sleep(0.6) json.dump(res, open(SC / "ks_meta.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) diff --git a/resources/knowledge/original/_pipeline/hwp5_text.py b/resources/knowledge/original/_pipeline/hwp5_text.py index 4f79f0c1..acdfa43b 100644 --- a/resources/knowledge/original/_pipeline/hwp5_text.py +++ b/resources/knowledge/original/_pipeline/hwp5_text.py @@ -1,20 +1,28 @@ # -*- coding: utf-8 -*- import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + """HWP5(OLE) 본문 텍스트 추출 — 순수 파이썬. BodyText/Section* 스트림을 (필요시 raw-deflate 해제) 레코드 파싱해 @@ -25,10 +33,11 @@ import olefile HWPTAG_BEGIN = 0x10 HWPTAG_PARA_HEADER = HWPTAG_BEGIN + 50 # 0x42 -HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 51 # 0x43 +HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 51 # 0x43 HWPTAG_CTRL_HEADER = HWPTAG_BEGIN + 55 # 0x47 HWPTAG_LIST_HEADER = HWPTAG_BEGIN + 56 # 0x48 -HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d +HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d + def is_compressed(ole): with ole.openstream("FileHeader") as f: @@ -37,35 +46,38 @@ def is_compressed(ole): flags = struct.unpack("> 10) & 0x3FF size = (header >> 20) & 0xFFF if size == 0xFFF: - size = struct.unpack("") for c in r) + " |") return ("table", "\n".join(out)) + def walk(container, out): for child in container: if _local(child.tag) != "p": @@ -68,6 +75,7 @@ def walk(container, out): else: out.append(("text", para_text(child))) + def extract(path): z = zipfile.ZipFile(path) secs = sorted(n for n in z.namelist() if re.search(r"Contents/section\d+\.xml$", n)) @@ -76,18 +84,20 @@ def extract(path): walk(ET.fromstring(z.read(sec)), out) return out + # ── 계층 마커: (정규식, 종류) — 리스트 깊이는 등장 순서 스택으로 결정 ── CHAP = re.compile(r"^제\d+\s*장(\s|$)") -SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.) +SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.) JO = re.compile(r"^제\d+조(의\d+)?\s*\(") MARKERS = [ - ("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")), - ("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")), - ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), - ("circ", re.compile(r"^([①-⑳])\s*(.*)$")), - ("dash", re.compile(r"^([-∙·○])\s+(.*)$")), + ("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")), + ("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")), + ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), + ("circ", re.compile(r"^([①-⑳])\s*(.*)$")), + ("dash", re.compile(r"^([-∙·○])\s+(.*)$")), ] + def marker(s): for k, rx in MARKERS: m = rx.match(s) @@ -95,10 +105,11 @@ def marker(s): return k, m.group(1), m.group(2) return None, "", s + def structure(items, header): """(kind, val) 아이템 리스트 → 구조화 md 라인. hwpx/hwp5 공용.""" lines = list(header) - stack = [] # 리스트 마커 종류 스택 + stack = [] # 리스트 마커 종류 스택 for kind, val in items: if kind == "table": lines += ["", val, ""] @@ -117,21 +128,26 @@ def structure(items, header): continue # 헤딩류 if CHAP.match(st): - lines += ["", f"## {st}", ""]; stack = []; continue + lines += ["", f"## {st}", ""] + stack = [] + continue if SECN.match(st): - lines += ["", f"### {st}", ""]; stack = []; continue + lines += ["", f"### {st}", ""] + stack = [] + continue if JO.match(st): m = re.match(r"^(제\d+조(?:의\d+)?\s*\([^)]*\))\s*(.*)$", st, re.S) lines += ["", f"### {m.group(1)}", ""] if m.group(2).strip(): lines.append(m.group(2).strip()) - stack = []; continue + stack = [] + continue # 리스트 마커 k, mk, rest = marker(st) if k: if k in stack: depth = stack.index(k) - del stack[depth + 1:] + del stack[depth + 1 :] else: stack.append(k) depth = len(stack) - 1 @@ -159,19 +175,23 @@ def structure(items, header): out = out.encode("utf-8", "ignore").decode("utf-8") return out + def to_md(path): items = extract(path) header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWPX 재추출)", ""] return structure(items, header) + def hwp5_to_md(path, header=None): """구형 HWP5(OLE) → 구조화 md. 표를 복원(extract_items)해 계층·표 보존.""" import hwp5_text + items = hwp5_text.extract_items(str(path)) if header is None: header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWP 재추출)", ""] return structure(items, header) + if __name__ == "__main__": for f in sys.argv[1:]: p = Path(f) diff --git a/resources/knowledge/original/_pipeline/index_entry.py b/resources/knowledge/original/_pipeline/index_entry.py index 9bec8430..869dac1a 100644 --- a/resources/knowledge/original/_pipeline/index_entry.py +++ b/resources/knowledge/original/_pipeline/index_entry.py @@ -4,6 +4,7 @@ 법률/행정규칙/표준시방서/<명칭>/ 하위의 본문·별표·첨부·압축해제·KCS md를 전부 링크. 최상위 `0. 참조 법령·기준 목록.md` 의 명칭이 이 _index.md 로 연결된다(gen_md). """ + import re, sys from pathlib import Path @@ -11,9 +12,11 @@ ROOT = Path(__file__).resolve().parent.parent CATS = ["법률", "행정규칙", "표준시방서"] SKIP = {"_index.md", "_목록.md"} + def rel(p, base): return p.relative_to(base).as_posix() + def build(folder): name = folder.name lines = [f"# {name} — 문서 목록", ""] @@ -24,7 +27,9 @@ def build(folder): lines.append("## 본문") lines.append("") for p in top: - label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get(p.stem, p.stem.replace("_", " ")) + label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get( + p.stem, p.stem.replace("_", " ") + ) lines.append(f"- [{label}](<{p.name}>)") lines.append("") @@ -70,7 +75,9 @@ def build(folder): if grp: lines.append(f" - **{grp}**") for p in tree[grp]: - lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)") + lines.append( + f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)" + ) else: for p in tree[grp]: lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.name}>)") @@ -86,12 +93,21 @@ def build(folder): lines.append(f"- [{p.stem}]()") lines.append("") - total = len(top) + len(byl) + len(att) + len(kcs) + sum( - len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs) - lines.insert(1, f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total-len(top)-len(byl)-len(att)-len(kcs)} · KCS {len(kcs)})") + total = ( + len(top) + + len(byl) + + len(att) + + len(kcs) + + sum(len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs) + ) + lines.insert( + 1, + f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total - len(top) - len(byl) - len(att) - len(kcs)} · KCS {len(kcs)})", + ) (folder / "_index.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") return total + if __name__ == "__main__": n = 0 for cat in CATS: diff --git a/resources/knowledge/original/_pipeline/index_images.py b/resources/knowledge/original/_pipeline/index_images.py index dc793bac..4ff6c648 100644 --- a/resources/knowledge/original/_pipeline/index_images.py +++ b/resources/knowledge/original/_pipeline/index_images.py @@ -4,6 +4,7 @@ 각 이미지: 미리보기 링크 · 크기 · 형식 · 소속(명칭) · 참조 md 링크. 사용자가 표로 옮길 이미지를 직접 판단한다. """ + import re from pathlib import Path from PIL import Image @@ -11,6 +12,7 @@ from PIL import Image ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "_이미지 목록(표 변환 검토용).md" + def _cand(p): try: w, h = Image.open(p).size @@ -18,6 +20,7 @@ def _cand(p): except Exception: return False + def build(): # 1) 이미지 → 참조 md 역매핑 ref = {} @@ -26,8 +29,11 @@ def build(): continue t = m.read_text(encoding="utf-8") # 경로에 괄호가 있어도 <...> 안이면 확장자까지 잡는다 - for mm in re.finditer(r'!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)' - r'|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)', t): + for mm in re.finditer( + r"!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)" + r"|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)", + t, + ): path = mm.group(1) or mm.group(2) img = (m.parent / path).resolve() ref.setdefault(str(img), []).append(m) @@ -37,7 +43,7 @@ def build(): groups = {} for p in imgs: rel = p.relative_to(ROOT) - parts = rel.parts # 분류/명칭/pic/파일 또는 분류/명칭/첨부/[압축]…/pic/… + parts = rel.parts # 분류/명칭/pic/파일 또는 분류/명칭/첨부/[압축]…/pic/… cat = parts[0] name = parts[1] if len(parts) > 2 else "(기타)" groups.setdefault((cat, name), []).append(p) @@ -53,31 +59,49 @@ def build(): seen, uniq = set(), [] for m in ref.get(str(p.resolve()), []): if m not in seen: - seen.add(m); uniq.append(m) - rlinks = " · ".join(f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)" - for m in uniq[:2]) if uniq else "_미참조_" + seen.add(m) + uniq.append(m) + rlinks = ( + " · ".join( + f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)" for m in uniq[:2] + ) + if uniq + else "_미참조_" + ) return f"| ☐ | {idx} | [{p.name[:40]}](<{rel}>) | {size} | {fmt} | {rlinks} |" cand = [p for p in imgs if _cand(p)] rest = [p for p in imgs if not _cand(p)] - L = ["# 이미지 목록 — 표 변환 검토용", "", - f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.", - "> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", "", - f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", "", - "> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", "", - "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", - "|:-:|---:|---|---|---|---|"] + L = [ + "# 이미지 목록 — 표 변환 검토용", + "", + f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.", + "> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", + "", + f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", + "", + "> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", + "", + "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", + "|:-:|---:|---|---|---|---|", + ] for i, p in enumerate(sorted(cand, key=lambda x: -Image.open(x).size[0]), 1): L.append(row(i, p)) - L += ["", f"## 그 외 이미지 ({len(rest)}개)", "", - "> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.", "", - "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", - "|:-:|---:|---|---|---|---|"] + L += [ + "", + f"## 그 외 이미지 ({len(rest)}개)", + "", + "> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.", + "", + "| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |", + "|:-:|---:|---|---|---|---|", + ] for i, p in enumerate(sorted(rest), len(cand) + 1): L.append(row(i, p)) OUT.write_text("\n".join(L) + "\n", encoding="utf-8") return len(imgs) + if __name__ == "__main__": n = build() print(f"이미지 목록 {n}개 → {OUT.name}") diff --git a/resources/knowledge/original/_pipeline/index_zip.py b/resources/knowledge/original/_pipeline/index_zip.py index 3ad320c0..e5f676bf 100644 --- a/resources/knowledge/original/_pipeline/index_zip.py +++ b/resources/knowledge/original/_pipeline/index_zip.py @@ -1,14 +1,20 @@ # -*- coding: utf-8 -*- """[압축] 폴더마다 _목록.md 생성 — 내부 HWP→md 파일 트리 색인.""" + import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent + def build(folder): mds = sorted(p for p in folder.rglob("*.md") if p.name != "_목록.md") - lines = [f"# {folder.name} — 압축 해제 문서 목록", "", - f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.", ""] + lines = [ + f"# {folder.name} — 압축 해제 문서 목록", + "", + f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.", + "", + ] # 하위 폴더 구조 반영 tree = {} for m in mds: @@ -27,6 +33,7 @@ def build(folder): (folder / "_목록.md").write_text("\n".join(lines), encoding="utf-8") return len(mds) + if __name__ == "__main__": n = 0 for folder in ROOT.rglob("[[]압축[]]*"): diff --git a/resources/knowledge/original/_pipeline/pdf2md.py b/resources/knowledge/original/_pipeline/pdf2md.py index 9e8826a4..8e7ea80e 100644 --- a/resources/knowledge/original/_pipeline/pdf2md.py +++ b/resources/knowledge/original/_pipeline/pdf2md.py @@ -5,42 +5,52 @@ - 본문은 법령 번호체계(Ⅰ./1./가./(1)/(가)/1)/가)/①) 기준으로 중첩 리스트화 - PDF 줄바꿈은 꼬리 공백을 신뢰해 그대로 이어붙임 (한글 어절 분리 방지) """ + import re, sys, json from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + import pymupdf ROOT = Path(str(ROOT_DIR)) # ── 마커 정의 (우선순위 순, 같은 종류끼리 같은 깊이) ── MARKERS = [ - ("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")), - ("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")), - ("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")), - ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), - ("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")), - ("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")), - ("korb", re.compile(r"^([가-힣]\))\s*(.*)$")), + ("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")), + ("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")), + ("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")), + ("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")), + ("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")), + ("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")), + ("korb", re.compile(r"^([가-힣]\))\s*(.*)$")), ("circle", re.compile(r"^([①-⑳])\s*(.*)$")), - ("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")), + ("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")), ] HEAD = re.compile(r"^■\s*(.+?)\s*\[(별표|별지)\s*([^\]]*)\]\s*(<[^>]*>)?\s*$") + def match_marker(s): for kind, rx in MARKERS: m = rx.match(s) @@ -48,6 +58,7 @@ def match_marker(s): return kind, m.group(1), m.group(2) return None, "", s + # ── 페이지 → (요소 리스트) ── def _lines(page): out = [] @@ -60,13 +71,17 @@ def _lines(page): out.append((pymupdf.Rect(ln["bbox"]), txt)) return out + def _nk(s): return re.sub(r"[^가-힣0-9A-Za-z%]", "", s) + def safe(s): return re.sub(r'[\\/:*?"<>|\s]+', "_", s).strip("_") -MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등) + +MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등) + def _images(page, pno, doc, picdir, stem): """페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환.""" @@ -84,16 +99,17 @@ def _images(page, pno, doc, picdir, stem): continue idx += 1 picdir.mkdir(parents=True, exist_ok=True) - fn = f"{stem}_p{pno+1}_{idx}.png" + fn = f"{stem}_p{pno + 1}_{idx}.png" try: - if px.n - px.alpha >= 4: # CMYK 등 → RGB + if px.n - px.alpha >= 4: # CMYK 등 → RGB px = pymupdf.Pixmap(pymupdf.csRGB, px) px.save(str(picdir / fn)) except Exception: continue - out.append((r, f"![그림 {pno+1}-{idx}](<../pic/{fn}>)")) + out.append((r, f"![그림 {pno + 1}-{idx}](<../pic/{fn}>)")) return out + def page_elements(page, pno=0, doc=None, picdir=None, stem=""): """세로 순서대로 ('text', y, x, 문자열) / ('table', y, x, md) / ('image', y, x, ref) 반환. @@ -112,15 +128,18 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""): if not md: continue b = pymupdf.Rect(t.bbox) - inside = "".join(_nk(txt) for r, txt in lines - if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2))) + inside = "".join( + _nk(txt) + for r, txt in lines + if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)) + ) if not inside: continue got = _nk(md) hit = sum(1 for ch in set(inside) if ch in got) cov = len(_nk(md)) / len(inside) if inside else 0 if cov < 0.90 or hit < len(set(inside)) * 0.95: - continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지 + continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지 final.append((b, md)) boxes = [b for b, _ in final] @@ -138,6 +157,7 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""): items.sort(key=lambda x: (round(x[1], 1), x[2])) return items + def table_md(t): try: rows = t.extract() @@ -152,8 +172,12 @@ def table_md(t): if len(cnts) == 1 and cnts and max(cnts) > 1: n = max(cnts) for i in range(n): - split.append([(p[i].strip() if len(p) == n else (r[j] if i == 0 else "")) - for j, p in enumerate(parts)]) + split.append( + [ + (p[i].strip() if len(p) == n else (r[j] if i == 0 else "")) + for j, p in enumerate(parts) + ] + ) else: split.append(r) rows = [[re.sub(r"\s+", " ", c).strip() for c in r] for r in split] @@ -163,19 +187,19 @@ def table_md(t): w = max(len(r) for r in rows) rows = [r + [""] * (w - len(r)) for r in rows] esc = lambda c: c.replace("|", "\\|").replace("\n", "
") - out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", - "|" + "|".join(["---"] * w) + "|"] + out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"] for r in rows[1:]: out.append("| " + " | ".join(esc(c) for c in r) + " |") return "\n".join(out) + # ── 변환 본체 ── def convert(pdf_path): doc = pymupdf.open(pdf_path) header, title = "", "" - body = [] # (depth, marker, text) | ("TABLE", md) - stack = [] # 마커 종류 스택 (index = depth) - cur = None # 현재 항목 dict + body = [] # (depth, marker, text) | ("TABLE", md) + stack = [] # 마커 종류 스택 (index = depth) + cur = None # 현재 항목 dict first_lines = [] def flush(): @@ -207,7 +231,7 @@ def convert(pdf_path): # PyMuPDF는 구조적 줄의 들여쓰기를 텍스트 안에 넣고 x0=좌측여백으로 둔다. # x0가 좌측여백보다 큰 줄 = 앞줄에서 넘어온 줄바꿈 조각. wrap = x0 > base_x + 2.0 - raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용) + raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용) s = raw.strip() if not s: continue @@ -225,23 +249,28 @@ def convert(pdf_path): if kind: if kind in stack: depth = stack.index(kind) - del stack[depth + 1:] + del stack[depth + 1 :] else: stack.append(kind) depth = len(stack) - 1 flush() - cur = {"kind": "item", "depth": depth, "marker": mk, - "head": rest.rstrip("\n"), "body": ""} + cur = { + "kind": "item", + "depth": depth, + "marker": mk, + "head": rest.rstrip("\n"), + "body": "", + } else: if cur is None: cur = {"kind": "item", "depth": 0, "marker": "", "head": "", "body": ""} - piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지 - if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐 + piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지 + if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐 if cur["body"]: cur["body"] += piece else: cur["head"] += piece - else: # 들여쓴 줄 = 새 본문 문단 + else: # 들여쓴 줄 = 새 본문 문단 if cur["body"]: cur["body"] += "\n\n" + piece else: @@ -291,10 +320,13 @@ def convert(pdf_path): md.append(l) return "\n".join(md).strip() + "\n" + if __name__ == "__main__": targets = sys.argv[1:] if not targets: - targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [str(p) for p in ROOT.rglob("첨부/*.pdf")] + targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [ + str(p) for p in ROOT.rglob("첨부/*.pdf") + ] ok = fail = 0 for f in targets: p = Path(f) diff --git a/resources/knowledge/original/_pipeline/qc_lint.py b/resources/knowledge/original/_pipeline/qc_lint.py index 955017f0..f034c57d 100644 --- a/resources/knowledge/original/_pipeline/qc_lint.py +++ b/resources/knowledge/original/_pipeline/qc_lint.py @@ -5,6 +5,7 @@ 소스 대조: 별표·첨부 md ↔ 같은 이름 PDF, 현행 본문 md ↔ 같은 이름 XML. 결과를 qc_report.json 으로 저장하고 카테고리별 요약 출력. """ + import json, re from pathlib import Path import pymupdf @@ -12,9 +13,11 @@ import pymupdf ROOT = Path(__file__).resolve().parent.parent OUT = Path(__file__).resolve().parent / "data" + def norm(s): return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s) + def strip_fenced(text): """``` 코드펜스 안을 빈 줄로 치환(위치 보존).""" out, infence = [], False @@ -26,6 +29,7 @@ def strip_fenced(text): out.append("" if infence else l) return chr(10).join(out) + def ncols(line): s = line.strip() if s.startswith("|"): @@ -34,6 +38,7 @@ def ncols(line): s = s[:-1] return len(s.split("|")) + def check_tables(text): """마크다운 표 유효성. (문제 리스트) 반환.""" issues = [] @@ -49,16 +54,19 @@ def check_tables(text): block.append(lines[i]) i += 1 head = ncols(block[0]) - if len(block) < 2 or not set(block[1].replace("|", "").replace(" ", "").replace(":", "")) <= set("-"): - issues.append(f"L{start+1} 구분선 없음/이상") + if len(block) < 2 or not set( + block[1].replace("|", "").replace(" ", "").replace(":", "") + ) <= set("-"): + issues.append(f"L{start + 1} 구분선 없음/이상") continue for j, b in enumerate(block): if j == 1: continue if ncols(b) != head: - issues.append(f"L{start+j+1} 열수 {ncols(b)}≠{head}") + issues.append(f"L{start + j + 1} 열수 {ncols(b)}≠{head}") return issues + def check_space(text): """프로즈(표·펜스 제외)의 한글 12자 이상 연속 비율.""" prose = [l for l in strip_fenced(text).split(chr(10)) if not l.lstrip().startswith("|")] @@ -69,24 +77,33 @@ def check_space(text): runs = re.findall(r"[가-힣]{12,}", t) return round(sum(len(x) for x in runs) / kor, 3) + def check_linebreak(text): """줄바꿈 결함: 표 앞 빈 줄 없음, 헤딩 직후 표 붙음.""" issues = [] lines = strip_fenced(text).split("\n") for i in range(1, len(lines)): s = lines[i].strip() - prev = lines[i-1].strip() + prev = lines[i - 1].strip() # 표 시작인데 앞 줄이 텍스트(표/빈줄/헤딩 아님) - if s.startswith("|") and prev and not prev.startswith("|") and not prev.startswith("#") and not prev.startswith(">"): - issues.append(f"L{i+1} 표 앞 빈 줄 없음") + if ( + s.startswith("|") + and prev + and not prev.startswith("|") + and not prev.startswith("#") + and not prev.startswith(">") + ): + issues.append(f"L{i + 1} 표 앞 빈 줄 없음") return issues[:5] + def pdf_stats(pdf): d = pymupdf.open(pdf) txt = "\n".join(p.get_text() for p in d) imgs = sum(len(p.get_images()) for p in d) return txt, imgs + def run(): report = [] targets = [] @@ -120,7 +137,7 @@ def run(): ptxt, pimgs = pdf_stats(pdf) pn, mn = norm(ptxt), norm(text) if pn and len(mn) / len(pn) < 0.98: - rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn)/len(pn):.2f})" + rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn) / len(pn):.2f})" mimg = text.count("〔그림〕") + text.count("![") if pimgs > 0 and mimg == 0: rec["issues"]["사진누락"] = f"PDF 이미지 {pimgs}개 / md 0" @@ -130,7 +147,9 @@ def run(): if rec["issues"]: report.append(rec) - json.dump(report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) + json.dump( + report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1 + ) # 요약 cat = {} @@ -142,8 +161,11 @@ def run(): print("\n=== 심각(내용누락·사진누락·테이블) 상위 ===") sev = [r for r in report if set(r["issues"]) & {"내용누락", "사진누락", "테이블"}] for r in sev[:30]: - ks = ", ".join(f"{k}={v if not isinstance(v,list) else len(v)}" for k, v in r["issues"].items()) + ks = ", ".join( + f"{k}={v if not isinstance(v, list) else len(v)}" for k, v in r["issues"].items() + ) print(f" {r['file'][-64:]} [{ks}]") + if __name__ == "__main__": run() diff --git a/resources/knowledge/original/_pipeline/split_cost_docs.py b/resources/knowledge/original/_pipeline/split_cost_docs.py index 2e4d6c9a..4358d998 100644 --- a/resources/knowledge/original/_pipeline/split_cost_docs.py +++ b/resources/knowledge/original/_pipeline/split_cost_docs.py @@ -13,6 +13,7 @@ - 품셈: 부문 표제 라인·목차 구역은 자동 탐지하지만 결과 요약(장 수)이 목차와 일치하는지 확인 - 공통: 원문 PDF 프로즈는 어절 공백이 붙는 특성 있음(값·표는 정상) — W5 공백 기준 예외로 기록 """ + import re import sys from pathlib import Path @@ -25,8 +26,13 @@ BASE = Path(__file__).resolve().parent.parent / "원가계산" # ────────────────────────── 건협 노임 ────────────────────────── CAK_DIR = "노임단가_건설업_대한건설협회" CAK_STEM = "2026상반기_건설업_임금실태조사_대한건설협회" -PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표 -CAK_CH = [("1. 조사개요", 1), ("2. 임금적용요령", 5), ("3. 개별직종 노임단가", 9), ("4. 직종해설", 13)] +PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표 +CAK_CH = [ + ("1. 조사개요", 1), + ("2. 임금적용요령", 5), + ("3. 개별직종 노임단가", 9), + ("4. 직종해설", 13), +] CAK_COLS = "| 직종코드 | 직종명 | 신뢰도 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |" @@ -51,8 +57,10 @@ def parse_cak_table(): if code not in result and len(slots) == 4 and name: result[code] = (name, slots, flag) rows = [CAK_COLS, "|---|---|---|---|---|---|---|"] - rows += [f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |" - for c in sorted(result)] + rows += [ + f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |" + for c in sorted(result) + ] return len(result), "\n".join(rows) + "\n" @@ -72,27 +80,36 @@ def split_cak(): if starts[0][1] is None: starts[0] = (starts[0][0], 0) assert all(s[1] is not None for s in starts), starts - hdr = (f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n" - "> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n") + hdr = ( + f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n" + "> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n" + ) for i, (fname, st) in enumerate(starts): en = starts[i + 1][1] if i + 1 < len(starts) else len(lines) body = "\n".join(lines[st:en]).strip() if "개별직종" in fname: - body = ("## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n" - f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n" - "> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n" - "> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), " - "`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table) + body = ( + "## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n" + f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n" + "> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n" + "> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), " + "`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table + ) (BASE / CAK_DIR / f"{fname}.md").write_text( - f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8") + f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8" + ) print("wrote", fname) # ────────────────────────── 중기중앙회 노임 ────────────────────────── KBIZ_DIR = "노임단가_제조업_중소기업중앙회" KBIZ_STEM = "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회" -KBIZ_CH = [("1. 조사개요", 1), ("2. 조사결과 요약", 12), ("3. 직종별 조사노임", 18), - ("4. 직종코드 및 직종명 해설", 30)] +KBIZ_CH = [ + ("1. 조사개요", 1), + ("2. 조사결과 요약", 12), + ("3. 직종별 조사노임", 18), + ("4. 직종코드 및 직종명 해설", 30), +] def split_kbiz(): @@ -102,13 +119,16 @@ def split_kbiz(): if starts[0][1] is None: starts[0] = (starts[0][0], 0) assert all(s[1] is not None for s in starts), starts - hdr = (f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n" - "> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n") + hdr = ( + f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n" + "> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n" + ) for i, (fname, st) in enumerate(starts): en = starts[i + 1][1] if i + 1 < len(starts) else len(lines) body = "\n".join(lines[st:en]).strip() (BASE / KBIZ_DIR / f"{fname}.md").write_text( - f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8") + f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8" + ) print("wrote", fname) @@ -121,8 +141,13 @@ def split_pumsem(): lines = (BASE / PUM_DIR / f"{PUM_STEM}.md").read_text(encoding="utf-8").splitlines() # 부문 표제 위치 자동 탐지 (짧은 단독 라인) - sec_names = [("01_공통부문", "공통부문"), ("02_토목부문", "토목부문"), ("03_건축부문", "건축부문"), - ("04_기계설비부문", "기계설비부문"), ("05_유지관리부문", "유지관리부문")] + sec_names = [ + ("01_공통부문", "공통부문"), + ("02_토목부문", "토목부문"), + ("03_건축부문", "건축부문"), + ("04_기계설비부문", "기계설비부문"), + ("05_유지관리부문", "유지관리부문"), + ] hits = {} for i, l in enumerate(lines): s = re.sub(r"[\s#>\-·ㆍ]", "", l) @@ -170,11 +195,14 @@ def split_pumsem(): outdir.mkdir(exist_ok=True) for j, (n, nm, st) in enumerate(starts): en = starts[j + 1][2] if j + 1 < len(starts) else b - hdr = (f"# {sec[3:]} 제{n}장 {nm}\n\n" - f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n" - "> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n") + hdr = ( + f"# {sec[3:]} 제{n}장 {nm}\n\n" + f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n" + "> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n" + ) (outdir / f"제{n}장_{nm}.md").write_text( - hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8") + hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8" + ) print(sec, f"{len(starts)}/{len(names)} 장") diff --git a/resources/knowledge/original/_pipeline/verify_pdf2md.py b/resources/knowledge/original/_pipeline/verify_pdf2md.py index b40e300f..c960e4be 100644 --- a/resources/knowledge/original/_pipeline/verify_pdf2md.py +++ b/resources/knowledge/original/_pipeline/verify_pdf2md.py @@ -1,33 +1,44 @@ # -*- coding: utf-8 -*- """PDF → md 변환 손실 검증: 정규화 문자 커버리지 + 줄 단위 누락 확인.""" + import re from pathlib import Path import os as _os from pathlib import Path as _P + # 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더. -ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original -DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) +ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original +DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON) + + # API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다. def _load_key(name): v = _os.environ.get(name) - if v: return v.strip() + if v: + return v.strip() sec = ROOT_DIR.parent / ".secrets.local.md" if sec.exists(): import re as _re + for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"): m = _re.search(pat, sec.read_text(encoding="utf-8")) - if m: return m.group(1) + if m: + return m.group(1) return "" + + import pymupdf ROOT = Path(str(ROOT_DIR)) + def norm(s): return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s) + bad, miss_lines, empty = [], [], [] tot = 0 -for pdf in sorted(list(ROOT.rglob("별표/*.pdf"))+list(ROOT.rglob("첨부/*.pdf"))): +for pdf in sorted(list(ROOT.rglob("별표/*.pdf")) + list(ROOT.rglob("첨부/*.pdf"))): md = pdf.with_suffix(".md") if not md.exists(): bad.append((pdf.name, "md 없음", 0, 0)) diff --git a/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py b/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py index d4c2bafb..5d3a9f65 100644 --- a/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py +++ b/resources/knowledge/original/원가계산/STmate/_scripts/extract_rounding.py @@ -47,7 +47,14 @@ def sheet_map(z): for name, rid in RE_SHEET.findall(wb): tgt = rels.get(rid) if tgt: - out.append((name, "xl/" + tgt.lstrip("/").replace("worksheets/", "worksheets/") if not tgt.startswith("xl/") else tgt)) + out.append( + ( + name, + "xl/" + tgt.lstrip("/").replace("worksheets/", "worksheets/") + if not tgt.startswith("xl/") + else tgt, + ) + ) return out diff --git a/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py b/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py index d2637865..e06f67aa 100644 --- a/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py +++ b/resources/knowledge/original/원가계산/STmate/_scripts/stc_cross_compare.py @@ -13,7 +13,9 @@ ROOT = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") KNOW = os.path.normpath(os.path.join(ROOT, "knowledge")) # 이 스크립트는 resources/knowledge/original/원가계산/STmate/_scripts/ 에 위치 # → knowledge 루트 = ../../../.. -KNOW = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..")) +KNOW = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..") +) sys.stdout.reconfigure(encoding="utf-8") diff --git a/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py b/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py index b2272930..4ec48612 100644 --- a/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py +++ b/resources/knowledge/original/원가계산/STmate/_scripts/xor_probe.py @@ -42,9 +42,7 @@ def records(path, table): def mode_key(recs, width): """열별 최빈 바이트 = 키스트림 후보 (공백 암호문 가정)""" - return bytes( - Counter(r[j] for r in recs).most_common(1)[0][0] for j in range(width) - ) + return bytes(Counter(r[j] for r in recs).most_common(1)[0][0] for j in range(width)) def probe(table, clip=None): @@ -62,9 +60,7 @@ def probe(table, clip=None): for name, key, nrec in keys: n = min(len(base_key), len(key)) same = sum(1 for a, b in zip(base_key[:n], key[:n]) if a == b) - print( - f" {name[:44]:46s} rec={nrec:4d} 일치 {same}/{n} ({round(same / n * 100)}%)" - ) + print(f" {name[:44]:46s} rec={nrec:4d} 일치 {same}/{n} ({round(same / n * 100)}%)") print() diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index 30ffcf75..c3c7fe98 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1221,6 +1221,62 @@ "fontFamily": "sans-serif" } } + }, + { + "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 316.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 36.0 + }, + { + "x": 316.0, + "y": 36.0 + } + ], + "imageData": "{{회사로고}}" + } + }, + { + "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 638.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 25.5 + }, + { + "x": 638.0, + "y": 25.5 + } + ], + "imageData": "{{설계자서명}}" + } } ], "layers": [ @@ -1237,4 +1293,4 @@ "isLocked": false } ] -} +} \ No newline at end of file diff --git a/scratch/test_vworld_download.py b/scratch/test_vworld_download.py index 83d4cb43..dc3100c5 100644 --- a/scratch/test_vworld_download.py +++ b/scratch/test_vworld_download.py @@ -10,22 +10,31 @@ sys.path.insert(0, str(PROJECT_ROOT)) from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map from B04_PreProcess.B04_PreProcess_Engine_GisVector import download_all_gis_vectors -PRJ_PATH = PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" -NPZ_PATH = PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed/ground_points_csf.npz" -OUTPUT_DIR = PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed" +PRJ_PATH = ( + PROJECT_ROOT + / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" +) +NPZ_PATH = ( + PROJECT_ROOT + / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed/ground_points_csf.npz" +) +OUTPUT_DIR = ( + PROJECT_ROOT / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B04_PreProcess/processed" +) + def main(): print("Loading NPZ to get bounds...") with np.load(NPZ_PATH) as data: bounds = data["bounds"] print("Raw bounds:", bounds) - + bounds_dict = { "x": [float(bounds[0, 0]), float(bounds[0, 1])], "y": [float(bounds[1, 0]), float(bounds[1, 1])], "z": [float(bounds[2, 0]), float(bounds[2, 1])], } - + print("\n--- Test 1: VWorld Satellite Map Download ---") try: res = download_vworld_satellite_map( @@ -33,7 +42,7 @@ def main(): bounds=bounds_dict, output_dir=OUTPUT_DIR, layer_name="Satellite", - ext="jpeg" + ext="jpeg", ) print("Download Result:", res) except Exception as e: @@ -42,15 +51,12 @@ def main(): print("\n--- Test 2: GIS Vector Download ---") try: - download_all_gis_vectors( - prj_path=PRJ_PATH, - bounds_meter=bounds_dict, - output_dir=OUTPUT_DIR - ) + download_all_gis_vectors(prj_path=PRJ_PATH, bounds_meter=bounds_dict, output_dir=OUTPUT_DIR) print("GIS Vector Download completed.") except Exception as e: print("GIS Download Failed:") traceback.print_exc() + if __name__ == "__main__": main() diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 0c87f8a9..d50e9d3b 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -82,7 +82,7 @@ export interface InputFieldOptions { label?: string; /** placeholder (i18n 결과) */ placeholder?: string; - type?: "text" | "password" | "email" | "number" | "search" | "tel"; + type?: "text" | "password" | "email" | "number" | "search" | "tel" | "date"; value?: string; required?: boolean; /** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */ diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 95f44e1b..97b8a443 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -29,31 +29,16 @@ export const ui_locales_b1 = { B01_Account_Field_Name: ["이름", "Name"], B01_Account_Field_Email: ["이메일", "Email"], B01_Account_Field_Phone: ["연락처", "Phone"], - B01_Account_Field_Phone_Placeholder: [ - "연락처를 입력하세요", - "Enter phone number", - ], + B01_Account_Field_Phone_Placeholder: ["연락처를 입력하세요", "Enter phone number"], B01_Account_Field_CurrentPw: ["현재 비밀번호", "Current password"], B01_Account_Field_NewPw: ["새 비밀번호", "New password"], B01_Account_Field_ConfirmPw: ["새 비밀번호 확인", "Confirm new password"], B01_Account_Save_Profile: ["기본 정보 저장", "Save profile"], B01_Account_Save_Password: ["비밀번호 변경", "Change password"], - B01_Account_Success_Profile: [ - "기본 정보가 저장되었습니다.", - "Profile has been saved.", - ], - B01_Account_Success_Password: [ - "비밀번호가 변경되었습니다.", - "Password has been changed.", - ], - B01_Account_Error_Required: [ - "필수 항목을 입력하세요.", - "Please fill in required fields.", - ], - B01_Account_Error_PwMismatch: [ - "새 비밀번호가 일치하지 않습니다.", - "New passwords do not match.", - ], + B01_Account_Success_Profile: ["기본 정보가 저장되었습니다.", "Profile has been saved."], + B01_Account_Success_Password: ["비밀번호가 변경되었습니다.", "Password has been changed."], + B01_Account_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], + B01_Account_Error_PwMismatch: ["새 비밀번호가 일치하지 않습니다.", "New passwords do not match."], B01_Account_Error_PwLength: [ "비밀번호는 8자 이상이어야 합니다.", "Password must be at least 8 characters.", @@ -94,14 +79,8 @@ export const ui_locales_b1 = { /* --- B01 임시 보관함 (프로젝트 생성 전 업로드, 2026-08-08) --- */ B01_Temp_Section: ["임시 보관함", "Temporary storage"], B01_Temp_Field_Name: ["보관 이름", "Storage name"], - B01_Temp_Field_Name_Placeholder: [ - "예: 2026년 3공구 측량자료", - "e.g. 2026 Section 3 survey", - ], - B01_Temp_Field_Files: [ - "파일 선택 (계획노선·라이다·좌표계·래스터)", - "Select files", - ], + B01_Temp_Field_Name_Placeholder: ["예: 2026년 3공구 측량자료", "e.g. 2026 Section 3 survey"], + B01_Temp_Field_Files: ["파일 선택 (계획노선·라이다·좌표계·래스터)", "Select files"], B01_Temp_Btn_Pick: ["파일 선택", "Choose files"], B01_Temp_Btn_Add: ["파일 추가", "Add files"], B01_Temp_Modal_Create: ["임시 자료 등록", "New stored set"], @@ -121,10 +100,7 @@ export const ui_locales_b1 = { "Delete this file from temporary storage?", ], B01_Temp_File_Delete_Success: ["파일을 삭제했습니다.", "File deleted."], - B01_Temp_File_Delete_Failed: [ - "파일 삭제에 실패했습니다.", - "Failed to delete the file.", - ], + B01_Temp_File_Delete_Failed: ["파일 삭제에 실패했습니다.", "Failed to delete the file."], /* 보관 기간은 섹션 제목 옆 태그로만 알린다(안내 문단 폐기, 2026-08-08). */ B01_Temp_Hint_Days: ["일 보관", " days retained"], B01_Temp_Status_Uploading: ["업로드 중", "Uploading"], @@ -135,18 +111,9 @@ export const ui_locales_b1 = { B01_Temp_Meta_Linked: ["프로젝트로 이동 완료", "Moved to project"], B01_Temp_Error_Name: ["보관 이름을 입력하세요.", "Enter a storage name."], B01_Temp_Error_Files: ["올릴 파일을 선택하세요.", "Select files to upload."], - B01_Temp_Upload_Success: [ - "보관함에 저장했습니다.", - "Saved to temporary storage.", - ], - B01_Temp_Upload_Failed: [ - "보관함 업로드에 실패했습니다.", - "Failed to upload.", - ], - B01_Temp_Load_Failed: [ - "보관함을 불러오지 못했습니다.", - "Failed to load storage.", - ], + B01_Temp_Upload_Success: ["보관함에 저장했습니다.", "Saved to temporary storage."], + B01_Temp_Upload_Failed: ["보관함 업로드에 실패했습니다.", "Failed to upload."], + B01_Temp_Load_Failed: ["보관함을 불러오지 못했습니다.", "Failed to load storage."], B01_Temp_Delete_Confirm: [ "이 보관 자료를 삭제할까요? 되돌릴 수 없습니다.", "Delete this stored set? This cannot be undone.", @@ -185,10 +152,7 @@ export const ui_locales_b1 = { B01_Dashboard_Modal_FindCompany: ["회사 검색", "Find company"], B01_Dashboard_Modal_AddMember: ["팀원 추가", "Add member"], B01_Dashboard_Saved: ["저장되었습니다.", "Saved."], - B01_Dashboard_LoadFailed: [ - "대시보드를 불러오지 못했습니다.", - "Failed to load dashboard.", - ], + B01_Dashboard_LoadFailed: ["대시보드를 불러오지 못했습니다.", "Failed to load dashboard."], B01_Dashboard_RequestFailed: ["요청 처리에 실패했습니다.", "Request failed."], // 프로젝트 관리 @@ -200,10 +164,7 @@ export const ui_locales_b1 = { B01_Dashboard_EditUser: ["사용자 수정", "Edit User"], B01_Dashboard_DeleteUser: ["사용자 삭제", "Delete User"], B01_Dashboard_ChangeRole: ["역할 변경", "Change Role"], - B01_Dashboard_SelectAvailableUsers: [ - "사용 가능한 사용자 선택", - "Select Available Users", - ], + B01_Dashboard_SelectAvailableUsers: ["사용 가능한 사용자 선택", "Select Available Users"], // 확인 메시지 B01_Dashboard_Confirm_DeleteProject: [ @@ -215,10 +176,7 @@ export const ui_locales_b1 = { "[하드 삭제 모드] 업로드한 라이다 원본과 모든 계산 결과가 서버에서 영구 삭제됩니다. 복구할 수 없습니다. 삭제하시겠습니까?", "[Hard delete mode] The uploaded LiDAR source and every computed result will be permanently erased from the server. This cannot be recovered. Delete anyway?", ], - B01_Dashboard_Confirm_DeleteUser: [ - "사용자를 삭제하시겠습니까?", - "Delete this user?", - ], + B01_Dashboard_Confirm_DeleteUser: ["사용자를 삭제하시겠습니까?", "Delete this user?"], B01_Dashboard_Confirm_LastAdmin: [ "회사의 유일한 관리자는 삭제할 수 없습니다.", "Cannot delete the last admin of the company.", @@ -249,24 +207,15 @@ export const ui_locales_b1 = { B02_Proj_RoadType_Work: ["작업임도", "Work forest road"], B02_Proj_Field_Year: ["사업 연도", "Project year"], B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"], - B02_Proj_Field_Length_Placeholder: [ - "예상 노선 길이", - "Estimated route length", - ], + B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"], B02_Proj_Field_Memo: ["비고", "Notes"], - B02_Proj_Field_Memo_Placeholder: [ - "추가 메모 (선택)", - "Additional notes (optional)", - ], + B02_Proj_Field_Memo_Placeholder: ["추가 메모 (선택)", "Additional notes (optional)"], B02_Proj_Submit: ["프로젝트 생성", "Create project"], B02_Proj_Success: [ "프로젝트가 생성되었습니다. 파일 입력 단계로 이동합니다.", "Project created. Moving to the file input step.", ], - B02_Proj_Error_Required: [ - "필수 항목을 입력하세요.", - "Please fill in required fields.", - ], + B02_Proj_Error_Required: ["필수 항목을 입력하세요.", "Please fill in required fields."], /* --- B03_FileInput 파일 입력 --- */ B03_File_Title: ["파일입력", "File Input"], @@ -287,10 +236,7 @@ export const ui_locales_b1 = { "현재 프로젝트가 선택되지 않았습니다. 프로젝트를 먼저 생성하거나 선택하세요.", "No current project is selected. Create or select a project first.", ], - B03_File_Error_Required: [ - "업로드할 파일을 선택하세요.", - "Select files to upload.", - ], + B03_File_Error_Required: ["업로드할 파일을 선택하세요.", "Select files to upload."], B03_File_Error_Count: [ "한 번에 업로드할 수 있는 파일 수를 초과했습니다.", "Too many files were selected for one upload.", @@ -303,22 +249,10 @@ export const ui_locales_b1 = { "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 넣을 수 없습니다.", "LAS/LAZ files cannot be added while designing without LAS.", ], - B03_File_Error_Extension: [ - "허용되지 않은 파일 형식입니다.", - "Unsupported file type.", - ], - B03_File_Error_Size: [ - "파일 크기 제한을 초과했습니다.", - "File size limit exceeded.", - ], - B03_File_Upload_Success: [ - "입력 파일 업로드를 완료했습니다.", - "Input files uploaded.", - ], - B03_File_Upload_Failed: [ - "파일 업로드에 실패했습니다.", - "File upload failed.", - ], + B03_File_Error_Extension: ["허용되지 않은 파일 형식입니다.", "Unsupported file type."], + B03_File_Error_Size: ["파일 크기 제한을 초과했습니다.", "File size limit exceeded."], + B03_File_Upload_Success: ["입력 파일 업로드를 완료했습니다.", "Input files uploaded."], + B03_File_Upload_Failed: ["파일 업로드에 실패했습니다.", "File upload failed."], B03_File_Analysis_InProgress: [ "WF1 분석이 백그라운드에서 진행 중입니다. 완료되면 자동으로 이동합니다.", "WF1 analysis is running in the background. You will move automatically when it completes.", @@ -361,10 +295,7 @@ export const ui_locales_b1 = { B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ - B03_Temp_Btn_Open: [ - "임시 보관함에서 불러오기", - "Load from temporary storage", - ], + B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"], B03_Temp_Selected: ["선택됨:", "Selected:"], B03_Temp_FileCount: ["개 파일", " files"], @@ -374,18 +305,12 @@ export const ui_locales_b1 = { "No usable stored set. Upload all required files in the dashboard temporary storage first.", ], B03_Temp_Select_Required: ["보관 자료를 선택하세요.", "Select a stored set."], - B03_Temp_Load_Failed: [ - "보관 자료를 불러오지 못했습니다.", - "Failed to load stored sets.", - ], + B03_Temp_Load_Failed: ["보관 자료를 불러오지 못했습니다.", "Failed to load stored sets."], B03_Temp_Attach_Success: [ "보관 자료를 프로젝트로 옮겼습니다. 분석을 시작합니다.", "Stored files moved to the project. Analysis started.", ], - B03_Temp_Attach_Failed: [ - "보관 자료 연결에 실패했습니다.", - "Failed to attach stored files.", - ], + B03_Temp_Attach_Failed: ["보관 자료 연결에 실패했습니다.", "Failed to attach stored files."], B03_Temp_Attach_NoAnalysis: [ "파일은 옮겼지만 라이다 파일이 없어 분석을 시작하지 못했습니다.", "Files moved, but analysis did not start (no point cloud file).", @@ -400,8 +325,8 @@ export const ui_locales_b1 = { ], B03_File_Error_RequiredSlots: [ "필수 카드를 모두 채우세요 — 계획노선(CSV 또는 shapefile 한 벌), LAS/LAZ, 지형 PRJ·TFW.", - "Fill every required card: the planned route (a CSV or a full shapefile set), " - + "LAS/LAZ, and the terrain PRJ and TFW.", + "Fill every required card: the planned route (a CSV or a full shapefile set), " + + "LAS/LAZ, and the terrain PRJ and TFW.", ], B03_File_Error_SlotType: [ "선택한 파일 유형이 이 카드와 맞지 않습니다.", @@ -415,10 +340,7 @@ export const ui_locales_b1 = { B03_File_Status_Completed: ["완료", "Completed"], B03_File_Status_Failed: ["실패", "Failed"], B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"], - B03_File_Restore_State: [ - "저장된 업로드/분석 상태 복구", - "Restored upload/analysis state", - ], + B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"], B03_File_Resume_Button: ["업로드 재개", "Resume upload"], B03_File_New_Button: ["새 파일로 시작", "Start new file"], B03_File_Overview_Complete: [ @@ -455,10 +377,7 @@ export const ui_locales_b1 = { B04_Surface_Group_Filters: ["지면 필터", "Ground filter"], B04_Surface_Group_Methods: ["서피스", "Surface"], B04_Surface_Group_Display: ["모델 표시 옵션", "Model display options"], - B04_Surface_SheetSurface: [ - "도엽등고 3D 서피스", - "Map-sheet contour 3D surface", - ], + B04_Surface_SheetSurface: ["도엽등고 3D 서피스", "Map-sheet contour 3D surface"], B04_Surface_SheetLidar: ["라이다 겹쳐 보기", "Overlay LiDAR"], B04_Surface_SheetLidar_Missing: [ "겹쳐 볼 라이다 지표면 모델이 없습니다.", @@ -485,16 +404,10 @@ export const ui_locales_b1 = { B04_Surface_Input_FileName: ["파일명", "File name"], B04_Surface_Input_Crs: ["좌표계", "CRS"], B04_Surface_Input_Size: ["크기(MB)", "Size (MB)"], - B04_Surface_PointCloud_Title: [ - "포인트클라우드 미리보기", - "Point cloud preview", - ], + B04_Surface_PointCloud_Title: ["포인트클라우드 미리보기", "Point cloud preview"], B04_Surface_Status_Unknown: ["상태 미확인", "Unknown"], B04_Surface_GroundStats_Title: ["지면 필터 통계", "Ground filter stats"], - B04_Surface_GroundStats_Empty: [ - "표시할 지면 통계가 없습니다.", - "No ground stats to display.", - ], + B04_Surface_GroundStats_Empty: ["표시할 지면 통계가 없습니다.", "No ground stats to display."], B04_Surface_GroundStats_Filter: ["필터", "Filter"], B04_Surface_GroundStats_SourcePoints: ["지면 포인트", "Ground points"], B04_Surface_Result_Title: ["생성된 지표면 모델", "Generated Surface Models"], @@ -512,10 +425,7 @@ export const ui_locales_b1 = { "모델을 확정했습니다. 필터: {filter}, 기법: {method}, 스무딩/표현: {smoothing}", "Model confirmed. Filter: {filter}, method: {method}, smoothing/representation: {smoothing}", ], - B04_Surface_Confirm_Failed: [ - "모델 확정에 실패했습니다.", - "Failed to confirm model.", - ], + B04_Surface_Confirm_Failed: ["모델 확정에 실패했습니다.", "Failed to confirm model."], B04_Surface_Build_Confirm: [ "이 조합({filter} · {method})은 아직 만들어지지 않았습니다.\n지금 계산해 영구 저장할까요? 자료량에 따라 수 분이 걸립니다.", "This combination ({filter} · {method}) has not been built yet.\nBuild and store it now? This can take several minutes depending on data size.", @@ -533,10 +443,7 @@ export const ui_locales_b1 = { "지표면 모델 계산에 실패했습니다.", "Failed to build the surface model.", ], - B04_Surface_Map_Title: [ - "2D 배경 지도 및 GIS 레이어", - "2D Basemap and GIS Layers", - ], + B04_Surface_Map_Title: ["2D 배경 지도 및 GIS 레이어", "2D Basemap and GIS Layers"], B04_Surface_Map_Background: ["배경 지도", "Basemap"], B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"], B04_Surface_Map_None: ["없음", "None"], @@ -567,20 +474,14 @@ export const ui_locales_b1 = { "Failed to load the drainage analysis.", ], /* {message}=원인 */ - B04_Surface_Watershed_Failed: [ - "유역 분석 실패: {message}", - "Basin analysis failed: {message}", - ], + B04_Surface_Watershed_Failed: ["유역 분석 실패: {message}", "Basin analysis failed: {message}"], B04_Surface_Watershed_NoSaved: [ "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", "No stored drainage analysis. Press [Basin analysis].", ], B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"], /* {seconds}=재산정에 걸린 시간(초) */ - B04_Surface_Watershed_Origin_Recomputed: [ - "재산정 {seconds}초", - "Recomputed in {seconds}s", - ], + B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"], /* 도로 유입 흐름 강도 */ B04_Surface_Flow_Strength: ["흐름 강도", "Flow strength"], B04_Surface_Flow_Strength_Tip: [ @@ -593,10 +494,7 @@ export const ui_locales_b1 = { "노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.", "Marks the spots along the route that collect the most water. Click a marker to outline the cells draining into it.", ], - B04_Surface_Flow_Inflow_Loading: [ - "유입 셀을 불러오는 중…", - "Loading the contributing cells…", - ], + B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"], /* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */ B04_Surface_Flow_Inflow_Summary: [ "유입 집중점 {index} · 측점 {chainage}m — 유입면적 {area} · 셀 {cells}개 · 최장 유하장 {path}m", @@ -668,37 +566,16 @@ export const ui_locales_b1 = { "배경 지도 또는 GIS 레이어를 선택하세요.", "Select a basemap or GIS layer.", ], - B04_Surface_Map_Loading: [ - "지도 레이어를 불러오는 중입니다.", - "Loading map layers.", - ], + B04_Surface_Map_Loading: ["지도 레이어를 불러오는 중입니다.", "Loading map layers."], B04_Surface_Map_Features: ["{count}개 객체 표시", "Showing {count} features"], - B04_Surface_Map_LoadFailed: [ - "지도 레이어를 불러오지 못했습니다.", - "Failed to load map.", - ], - B04_Surface_Error_Project: [ - "먼저 프로젝트를 선택하세요.", - "Select a project first.", - ], - B04_Surface_Error_InputId: [ - "유효한 입력 파일 ID를 입력하세요.", - "Enter a valid input file ID.", - ], + B04_Surface_Map_LoadFailed: ["지도 레이어를 불러오지 못했습니다.", "Failed to load map."], + B04_Surface_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], + B04_Surface_Error_InputId: ["유효한 입력 파일 ID를 입력하세요.", "Enter a valid input file ID."], B04_Surface_Error_Selection: [ "지면 필터와 지표면 표현을 각각 1개 이상 선택하세요.", "Select at least one filter and one method.", ], - B04_Surface_Analyze_Success: [ - "지표면 분석을 완료했습니다.", - "Surface analysis complete.", - ], - B04_Surface_Analyze_Failed: [ - "지표면 분석에 실패했습니다.", - "Surface analysis failed.", - ], - B04_Surface_Load_Failed: [ - "모델 목록을 불러오지 못했습니다.", - "Failed to load models.", - ], + B04_Surface_Analyze_Success: ["지표면 분석을 완료했습니다.", "Surface analysis complete."], + B04_Surface_Analyze_Failed: ["지표면 분석에 실패했습니다.", "Surface analysis failed."], + B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."], } as const satisfies Record; diff --git a/vite.config.ts b/vite.config.ts index 9f3b729f..2dbf28fe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,7 +25,9 @@ export default { }, }, server: { - port: 5173, + // main.py가 CLI --port로 덮지만, `npm run dev` 맨손 실행에서 보조 워크트리가 + // 메인의 5173을 뺏지 않도록 env를 먼저 본다(dev_up.py가 주입한다). + port: Number(process.env.FRONTEND_DEV_PORT ?? 5173), open: false, // 백엔드(FastAPI) 프록시 — config_frontend.ts의 API_BASE_URL과 정합. // 포트는 AISLO_API_PORT로 바꿀 수 있다(기본 8000) — 워크트리를 나눠 쓰는 병행