Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 — Router_Drawing 은 sub2 것 · Router_Layers 는 sub4 것

This commit is contained in:
2026-09-25 09:55:32 +09:00
27 changed files with 2269 additions and 418 deletions
@@ -175,7 +175,7 @@ export interface FrameTemplateResponse {
status: string;
project_id: string;
drawing: CadDrawing;
/** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */
/** 프로젝트 도각이 처음 복사한 도각과 다르면 true. */
customized: boolean;
/** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */
fields?: Record<string, string>;
@@ -237,7 +237,7 @@ export async function exportDrawing(
};
}
/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */
/** 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다. */
export function resetFrameTemplate(projectId: string): Promise<void> {
return requestJson(`/projects/${projectId}/frame-template`, {
method: "DELETE",
@@ -1,12 +1,13 @@
"""B07 도각 템플릿 병합 — openwebcad JSON 템플릿을 도면 콘텐츠 둘레에 배치한다.
resources/template_2dDrawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
resources/master_template/drawing/의 사전 변환 템플릿(A1 도각 등)을 로드해,
도면 콘텐츠 bbox에 맞춰 균등 스케일·이동시킨 뒤 잠금 프레임 레이어
(b08-frame) 엔티티로 병합한다. 콘텐츠 좌표(m)는 건드리지 않는다 —
템플릿 쪽을 확대해 콘텐츠를 감싼다.
A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표제란,
내부 작도 영역 (42, 47) ~ (812, 567).
내부 작도 영역 (42, 47) ~ (812, 567). 작도 영역은 양식 칸 `drawing_area` 가 정본이고
칸이 없는 양식(옛 회사 도각)은 이 값으로 떨어진다.
"""
import binascii
@@ -26,8 +27,19 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
FRAME_LAYER_ID,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
from M02_MasterTemplete.M02_Template_Layers import (
delete_template,
initial_dir,
project_dir,
reset_project,
system_dir,
template_path,
version_of,
write_template,
)
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
# 도면 양식은 M02 양식 층(PLAN 10-5)의 `drawing` 종류다.
_KIND = "drawing"
logger = logging.getLogger(__name__)
@@ -37,29 +49,27 @@ _SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector")
A1_TEMPLATE = "00_template_A1"
COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표
# A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다.
# A1 내부 작도 영역(템플릿 좌표) — 양식에 `drawing_area` 칸이 없을 때만 쓴다.
_A1_INNER = (42.0, 47.0, 812.0, 567.0)
# 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향). 5%는 도각 안쪽에 70mm 가까이를
# 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자).
_CONTENT_MARGIN = 0.02
# 회사가 자기 도각을 두는 자리 — `storage/{회사}/templates/`. 프로그램 기본 도각
# (`resources/template_2dDrawing/`)은 **읽기 전용**이고, 고객이 고친 도각은 여기 쌓인다
# (2026-09-01 사용자 확정: "정본은 그냥 두고 수정하는 기능").
COMPANY_TEMPLATE_SUBDIR = "templates"
# 이 요청이 읽을 회사 도각 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
# 읽는 차례 = 프로젝트 작업본(`{프로젝트}/templates/drawing/`) → 없으면 시스템 양식
# (`resources/master_template/drawing/`, 읽기 전용). 설계자가 고친 도각은 작업본에 쌓인다.
#
# 이 요청이 읽을 프로젝트 폴더. 라우터가 요청마다 세운다 — 엔진 6개(종단·횡단장·
# 토적도·유역도·표지·공용)의 서명을 줄줄이 고치지 않으려고 문맥 변수를 쓴다.
# `asyncio.to_thread`가 문맥을 복사하므로 스레드로 넘어간 작도에도 그대로 따라간다.
_company_dir: ContextVar[Path | None] = ContextVar("b07_company_template_dir", default=None)
_project_root: ContextVar[Path | None] = ContextVar("b07_project_template_root", default=None)
def use_company_templates(company_dir: Path | None) -> None:
"""이 요청이 읽을 회사 도각 폴더를 정한다. None이면 프로그램 기본 도각."""
_company_dir.set(company_dir)
def use_project_templates(project_root: Path | None) -> None:
"""이 요청이 읽을 프로젝트 폴더를 정한다. None이면 시스템 양식."""
_project_root.set(project_root)
# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 —
# 이 요청이 도각 표제란에 채울 값. 프로젝트 폴더와 같은 이유로 문맥 변수다 —
# 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다.
_title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={})
@@ -78,9 +88,14 @@ def add_title_fields(extra: dict[str, str]) -> None:
_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"
def project_template_path(project_root: Path, name: str = A1_TEMPLATE) -> Path:
"""프로젝트 작업본 도각 파일 경로(없을 수도 있다)."""
return template_path(project_dir(project_root), _KIND, name)
def system_template_path(name: str = A1_TEMPLATE) -> Path:
"""시스템 도각 파일 경로."""
return template_path(system_dir(), _KIND, name)
@lru_cache(maxsize=16)
@@ -90,18 +105,35 @@ def _read_template(path_str: str, mtime_ns: int) -> dict[str, Any]:
def _load_template(name: str) -> dict[str, Any] | None:
"""회사 도각이 있으면 그것, 없으면 프로그램 기본 도각."""
company_dir = _company_dir.get()
if company_dir is not None:
override = company_template_path(company_dir, name)
"""프로젝트 작업본이 있으면 그것, 없으면 시스템 양식(옛 프로젝트)."""
project_root = _project_root.get()
if project_root is not None:
override = project_template_path(project_root, name)
if override.is_file():
return _read_template(str(override), override.stat().st_mtime_ns)
path = _TEMPLATE_DIR / f"{name}.json"
path = system_template_path(name)
if not path.is_file():
return None
return _read_template(str(path), path.stat().st_mtime_ns)
def drawing_area(name: str = A1_TEMPLATE) -> tuple[float, float, float, float]:
"""양식의 내부 작도 영역 (x0, y0, x1, y1) — 콘텐츠가 이 영역 중앙에 놓인다.
양식 칸 `drawing_area` 를 읽는다. 없거나 어긋나면 A1 기본값(`_A1_INNER`).
"""
area = (_load_template(name) or {}).get("drawing_area")
if (
isinstance(area, list)
and len(area) == 4
and all(isinstance(value, (int, float)) for value in area)
and area[0] < area[2]
and area[1] < area[3]
):
return (float(area[0]), float(area[1]), float(area[2]), float(area[3]))
return _A1_INNER
def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]:
"""도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다."""
template = _load_template(name)
@@ -157,33 +189,52 @@ def validate_template_entities(entities: list[dict[str, Any]]) -> None:
_check(entity, f"#{index}")
def clear_company_template(company_dir: Path, name: str = A1_TEMPLATE) -> bool:
"""회사 도각을 지워 프로그램 기본 도각으로 되돌린다. 지울 것이 없으면 False."""
path = company_template_path(company_dir, name)
if not path.is_file():
def is_project_template_customized(project_root: Path, name: str = A1_TEMPLATE) -> bool:
"""작업본이 처음 복사한 도각(`_initial/`, 없으면 시스템)과 다른가."""
current = version_of(project_template_path(project_root, name))
if current is None:
return False
path.unlink()
return True
initial = version_of(template_path(initial_dir(project_root), _KIND, name))
return current != (initial or version_of(system_template_path(name)))
def save_company_template(
company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
def reset_project_template(project_root: Path, name: str = A1_TEMPLATE) -> bool:
"""[기본 도각으로] — 처음 복사한 도각(`_initial/`)을 작업본에 덮어쓴다.
`_initial/` 이 없는 옛 프로젝트는 작업본을 지워 시스템 양식으로 떨어뜨린다.
되돌릴 것이 없으면 False.
"""
if reset_project(project_root, kind=_KIND, name=name):
return True
return delete_template(project_dir(project_root), _KIND, name)
def save_project_template(
project_root: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
) -> Path:
"""편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다."""
"""편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 건드리지 않는다."""
validate_template_entities(entities)
path = company_template_path(company_dir, name)
path.parent.mkdir(parents=True, exist_ok=True)
# 작도 영역은 CAD 가 모르는 칸이라 편집본에 없다 — 고치기 전 양식의 값을 이어 받는다.
area = (_load_template_at(project_root, name) or {}).get("drawing_area")
document = {
"format": DRAWING_FORMAT,
"source": "B07 도각 편집 화면",
**({"drawing_area": area} if area else {}),
# 도각은 도면마다 잠금 층 하나로 붙으므로 편집 중 새로 만든 층은 도각으로 모은다.
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities],
"layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}],
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(document, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
return path
write_template(project_dir(project_root), _KIND, name, document)
return project_template_path(project_root, name)
def _load_template_at(project_root: Path, name: str) -> dict[str, Any] | None:
"""요청 문맥과 상관없이 이 프로젝트가 읽는 도각."""
token = _project_root.set(project_root)
try:
return _load_template(name)
finally:
_project_root.reset(token)
def entities_bbox(entities: list[dict[str, Any]]) -> tuple[float, float, float, float] | None:
@@ -293,15 +344,15 @@ def usable_bbox() -> tuple[float, float, float, float]:
여백을 뺀 한도(`usable_area()`)보다 커서 "작도 영역을 넘습니다" 경고가 뜬다 —
내용이 없는데 넘칠 리 없다. 중심이 같으므로 도각 배치(이동량 0)는 그대로다.
"""
ix0, iy0, ix1, iy1 = _A1_INNER
ix0, iy0, ix1, iy1 = drawing_area()
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
def usable_area(name: str = A1_TEMPLATE) -> tuple[float, float]:
"""양식 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도."""
ix0, iy0, ix1, iy1 = drawing_area(name)
return (
(ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN),
(iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN),
@@ -444,8 +495,8 @@ def frame_entities(
content_w = max(max_x - min_x, 1e-6)
content_h = max(max_y - min_y, 1e-6)
ix0, iy0, ix1, iy1 = _A1_INNER
usable_w, usable_h = usable_area()
ix0, iy0, ix1, iy1 = drawing_area(template_name)
usable_w, usable_h = usable_area(template_name)
scale = max(content_w / usable_w, content_h / usable_h) if fit else 1.0
# 한도와 **같은** 크기는 넘친 것이 아니다 — 부동소수 오차만큼의 여유를 둔다
# (수용 한도를 그대로 넘기는 빈 도면이 마지막 자리 오차로 경고를 냈다).
+3 -4
View File
@@ -28,7 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
extract_quantity_table,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_company_templates,
use_project_templates,
use_title_fields,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
@@ -292,9 +292,8 @@ async def get_design_drawing(
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
use_company_templates(project_root.parent.parent)
# 프로젝트 작업본 도각이 있으면 그것으로 그린다(없으면 시스템 양식 — 옛 프로젝트).
use_project_templates(project_root)
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
use_title_fields(await title_block_fields(project_id))
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
@@ -18,12 +18,12 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
clear_company_template,
company_template_path,
frame_document,
frame_template_document,
save_company_template,
use_company_templates,
is_project_template_customized,
reset_project_template,
save_project_template,
use_project_templates,
validate_template_entities,
)
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
@@ -42,28 +42,27 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
async def _company_dir(project_id: UUID) -> Path:
"""프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`."""
async def _project_root(project_id: UUID) -> Path:
"""프로젝트 저장 폴더 — `storage/{회사}/{사용자}/{프로젝트}`. 도각 작업본이 여기 산다."""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
root = Path(resolve_stored_project_path(stored_path)).resolve()
return root.parent.parent
return Path(resolve_stored_project_path(stored_path)).resolve()
@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse)
async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse:
"""도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본."""
"""도각 편집 화면이 실을 도각 한 장. 프로젝트 작업본이 있으면 그것, 없으면 시스템 양식."""
try:
company_dir = await _company_dir(project_id)
use_company_templates(company_dir)
project_root = await _project_root(project_id)
use_project_templates(project_root)
# 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는
# 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다).
fields = await title_block_fields(project_id)
return FrameTemplateResponse(
project_id=str(project_id),
drawing=frame_template_document(),
customized=company_template_path(company_dir).is_file(),
customized=is_project_template_customized(project_root),
fields=fields,
)
except FileNotFoundError as exc:
@@ -123,7 +122,7 @@ async def import_frame_template(
) -> FrameTemplateImportResponse | JSONResponse:
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다.
"""
try:
data = await file.read()
@@ -150,7 +149,7 @@ async def import_frame_template(
async def put_frame_template(
project_id: UUID, request: FrameTemplateSaveRequest
) -> FrameTemplateSaveResponse | JSONResponse:
"""편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다.
"""편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 그대로 둔다.
이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면
다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정).
@@ -159,8 +158,8 @@ async def put_frame_template(
entities = request.drawing.get("entities")
if not isinstance(entities, list):
raise ValueError("도각 엔티티가 없습니다.")
company_dir = await _company_dir(project_id)
await asyncio.to_thread(save_company_template, company_dir, entities)
project_root = await _project_root(project_id)
await asyncio.to_thread(save_project_template, project_root, entities)
return FrameTemplateSaveResponse(project_id=str(project_id))
except (FileNotFoundError, ValueError) as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -174,15 +173,18 @@ async def put_frame_template(
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
"""[기본 도각으로] — 프로젝트 `_initial/` 도각을 작업본에 덮어쓴다(재계산 아님).
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 되돌린 도각으로 나온다.
"""
try:
company_dir = await _company_dir(project_id)
removed = await asyncio.to_thread(clear_company_template, company_dir)
return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed)
project_root = await _project_root(project_id)
await asyncio.to_thread(reset_project_template, project_root)
return FrameTemplateSaveResponse(
project_id=str(project_id),
customized=is_project_template_customized(project_root),
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
+1 -1
View File
@@ -137,7 +137,7 @@ class FrameTemplateSaveRequest(BaseModel):
class FrameTemplateSaveResponse(BaseModel):
"""회사 도각 저장 결과."""
"""프로젝트 도각 저장 결과."""
status: str = "success"
project_id: str
+33 -197
View File
@@ -38,14 +38,20 @@ import {
exportDrawing,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchFrameTemplate,
importFrameTemplate,
invalidateDesignDrawing,
resetFrameTemplate,
saveFrameTemplate,
type CadDrawing,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
@@ -62,33 +68,13 @@ interface DesignMeta {
}
/** CAD 저장 응답 (도면 + 편집된 수량표). */
interface SaveResult {
drawing: CadDrawing;
quantityTable: QuantityTable | null;
}
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
const B07_CAD_APP_URL = "/b07-cad/index.html";
type SaveResult = CadSaveResult<CadDrawing, QuantityTable>;
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
/* -----------------------------------------------------------------------------
* 화면 조립
* -------------------------------------------------------------------------- */
@@ -114,71 +100,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
: "도면 목록을 불러오지 못했습니다.";
}
const cadHost = document.createElement("div");
cadHost.className = "b07-cad-host";
const frame = document.createElement("iframe");
frame.className = "b07-cad-frame";
frame.src = B07_CAD_APP_URL;
frame.title = L("B07_Design_Title");
const license = document.createElement("a");
license.className = "b07-cad-license";
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
license.target = "_blank";
license.rel = "noreferrer";
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
cadHost.append(frame, license);
let cadReady = false;
let pendingLoad:
| {
drawing: CadDrawing;
meta: DesignMeta | null;
frameEdit: boolean;
frameFields: Record<string, string>;
}
| undefined;
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
const CAD_READY_TIMEOUT_MS = 20000;
const CAD_LOAD_TIMEOUT_MS = 15000;
let loadWatchdog: number | undefined;
const failCad = (detail: string): void => {
cadHost.dataset.loading = "false";
cadHost.dataset.error = detail;
showToast(detail, "error");
};
const stopLoadWatchdog = (): void => {
if (loadWatchdog === undefined) return;
window.clearTimeout(loadWatchdog);
loadWatchdog = undefined;
};
const startLoadWatchdog = (): void => {
stopLoadWatchdog();
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
loadWatchdog = window.setTimeout(() => {
loadWatchdog = undefined;
if (cadHost.dataset.loading !== "true") return;
failCad(
cadReady
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
);
}, wait);
};
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
frame.addEventListener("error", () => {
stopLoadWatchdog();
failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
const cad = createCadHost<CadDrawing, DesignMeta, QuantityTable>({
title: L("B07_Design_Title"),
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
onChanged: (dirty) => {
if (!frameEditor.isEditing()) cadDirty = dirty;
},
onNavigate: (direction) => navigateDrawing(direction),
onExport: (fileFormat) => void exportDrawingFile(fileFormat),
});
let currentDrawing: DesignDrawingItem | undefined;
@@ -188,7 +119,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let resolveSave: ((payload: SaveResult) => void) | undefined;
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
@@ -262,23 +192,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
hasNext: index < drawings.length - 1,
});
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
const sendLoad = (
drawing: CadDrawing,
meta: DesignMeta | null,
frameEdit = false,
frameFields: Record<string, string> = {},
) => {
pendingLoad = { drawing, meta, frameEdit, frameFields };
if (!cadReady) return;
frame.contentWindow?.postMessage(
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields },
window.location.origin,
);
pendingLoad = undefined;
};
/**
* 도면 캐시 — 진입 직후 **목록의 모든 도면**을 배경에서 받아 둔다(2026-08-30 사용자 지시).
*
@@ -349,9 +262,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
highlightActive(drawing.id);
const button = findButton(drawing.id);
if (button) button.dataset.loading = "true";
cadHost.dataset.loading = "true";
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
cad.beginLoading(); // 저쪽이 말이 없으면 시계가 끊는다
try {
const response = await requestDrawing(drawing);
currentDrawing = drawing;
@@ -360,10 +271,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
applyConfirmButtonState();
updateInfoPanel(drawing, response);
sendLoad(response.drawing, buildMeta(drawing, response, index));
cad.load(response.drawing, buildMeta(drawing, response, index));
} catch (error) {
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
if (currentDrawing) highlightActive(currentDrawing.id);
} finally {
loadInFlight = false;
@@ -384,16 +294,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
void loadDrawing(drawings[target], target);
};
const requestCadDrawing = (): Promise<SaveResult> =>
new Promise((resolve, reject) => {
resolveSave = resolve;
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
window.setTimeout(() => {
if (!resolveSave) return;
resolveSave = undefined;
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
}, 5000);
});
const requestCadDrawing = (): Promise<SaveResult> => cad.requestSave();
async function confirmCurrentDrawing(): Promise<void> {
if (!projectId || !currentDrawing) return;
@@ -501,9 +402,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
}
const frameEditor = createFrameTemplateEditor({
projectId: projectId as string,
sendLoad,
const frameEditor = createFrameTemplateEditor<CadDrawing>({
api: {
fetch: () => fetchFrameTemplate(projectId as string),
save: (drawing) => saveFrameTemplate(projectId as string, drawing),
importFile: (file) => importFrameTemplate(projectId as string, file),
reset: () => resetFrameTemplate(projectId as string),
},
sendLoad: (drawing, meta, frameEdit, frameFields) =>
cad.load(drawing, meta, frameEdit, frameFields),
requestCadDrawing: async () => (await requestCadDrawing()).drawing,
restoreDrawing: () => {
if (currentDrawing) void loadDrawing(currentDrawing, currentIndex);
@@ -513,77 +420,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null,
});
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
const message = event.data as {
type?: string;
detail?: string;
drawing?: CadDrawing;
quantityTable?: QuantityTable | null;
direction?: "prev" | "next";
fileFormat?: "dxf" | "dwg";
dirty?: boolean;
kind?: string;
text?: string;
actionId?: string;
durationMs?: number;
};
if (message.type === CAD_TOAST_MESSAGE) {
const kind = (["info", "success", "warning", "error"] as const).find(
(item) => item === message.kind,
);
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
const actionId = message.actionId;
showToast(
message.text ?? "",
kind ?? "info",
duration,
actionId
? () =>
frame.contentWindow?.postMessage(
{ type: CAD_TOAST_ACTION_MESSAGE, actionId },
window.location.origin,
)
: undefined,
);
} else if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad) {
sendLoad(
pendingLoad.drawing,
pendingLoad.meta,
pendingLoad.frameEdit,
pendingLoad.frameFields,
);
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
if (cadHost.dataset.loading === "true") startLoadWatchdog();
}
} else if (message.type === CAD_LOADED_MESSAGE) {
stopLoadWatchdog();
cadHost.dataset.loading = "false";
} else if (message.type === CAD_ERROR_MESSAGE) {
stopLoadWatchdog();
failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
} else if (message.type === CAD_CHANGED_MESSAGE) {
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
navigateDrawing(message.direction);
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
void exportDrawingFile(message.fileFormat);
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
const resolve = resolveSave;
resolveSave = undefined;
resolve({
drawing: message.drawing,
quantityTable: message.quantityTable ?? null,
});
}
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
@@ -604,7 +440,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
steps: workflowSteps(),
activeStep: 4,
leftPanel: drawingPanel,
mainContent: cadHost,
mainContent: cad.element,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
@@ -624,7 +460,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
root.replaceChildren(layout.root);
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
// CAD가 아직 준비 전이면 sendLoad가 pendingLoad로 대기했다가 ready 시 전송한다.
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
if (drawings.length > 0) {
void loadDrawing(drawings[0], 0).then(() => {
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
@@ -157,76 +157,6 @@
width: 100%;
}
/* CAD 뷰어 호스트 (상세 페이지 영역) */
.b07-cad-host {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
overflow: hidden;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
.b07-cad-host[data-loading="true"]::after {
content: "도면을 불러오는 중…";
position: absolute;
z-index: 4;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 10px 18px;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
color: var(--color-text);
font-size: 13px;
}
.b07-cad-host[data-error]:not([data-error=""])::before {
content: attr(data-error);
position: absolute;
z-index: 5;
top: 12px;
left: 50%;
transform: translateX(-50%);
max-width: 70%;
padding: 8px 16px;
border: 1px solid var(--color-danger, #d33);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
color: var(--color-danger, #d33);
font-size: 12px;
}
/* B07 독립형 CAD 앱 임베드 */
.b07-cad-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.b07-cad-license {
position: absolute;
z-index: 2;
right: 10px;
bottom: 7px;
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
font-size: 9px;
line-height: 1;
text-decoration: none;
}
.b07-cad-license:hover {
color: var(--color-text-muted);
text-decoration: underline;
}
/* 선택 횡단도의 지반정보/계획정보 (잠정치) */
.b07-info-host:empty {
display: none;
@@ -294,40 +224,6 @@
color: var(--color-text-muted);
}
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
.b07-frame-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
.b07-frame-edit[hidden] {
display: none;
}
.b07-frame-edit__label {
font-size: 0.78rem;
line-height: 1.4;
color: var(--color-text);
}
.b07-frame-edit__buttons {
display: flex;
gap: var(--spacing-8);
}
.b07-frame-edit__buttons > button {
flex: 1 1 0;
min-width: 0;
padding: 4px 8px;
font-size: 0.78rem;
}
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
.b07-drawing-list__bypass {
margin: var(--spacing-4) 0 0;
@@ -20,6 +20,7 @@ import {
setDesignMeta,
setEntities,
setFrameEditMode,
setHostReadOnly,
setLayers,
} from '../state.ts';
import { toast } from 'react-toastify';
@@ -45,6 +46,10 @@ interface DrawingLoadMessage {
frameEdit?: boolean;
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
frameFields?: Record<string, string>;
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 수량 패널 없이 싣는 M02 양식이 쓴다. */
recoveryScope?: string;
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
readOnly?: boolean;
}
interface DrawingSaveRequestMessage {
@@ -187,6 +192,7 @@ export function registerAisloDrawingBridge() {
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
setDesignMeta(event.data.meta ?? null);
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
setHostReadOnly(event.data.readOnly === true);
if (event.data.frameEdit) applyFramePreview();
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
@@ -194,7 +200,7 @@ export function registerAisloDrawingBridge() {
// 상태를 이어 갈 이유도 없다.
runCommandInput('SELECT');
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
setRecoveryScope(event.data.meta?.drawingId ?? null);
setRecoveryScope(event.data.recoveryScope ?? event.data.meta?.drawingId ?? null);
getScreenCanvasDrawController().zoomToFitScreen();
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
} catch (error) {
+8 -1
View File
@@ -190,6 +190,8 @@ let designMeta: DesignMeta | null = null;
let frameEditMode = false;
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
let frameFields: Record<string, string> = {};
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
let hostReadOnly = false;
/**
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
@@ -267,7 +269,7 @@ export const isDrawingDirty = () => drawingDirty;
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
*/
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true || hostReadOnly;
// setters
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
@@ -493,6 +495,11 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
triggerReactUpdate(StateVariable.designMeta);
};
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
export const setHostReadOnly = (readOnly: boolean) => {
hostReadOnly = readOnly;
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
frameEditMode = enabled;
frameFields = enabled ? fields : {};
@@ -0,0 +1,66 @@
/* M02 도면 양식 편집 부품 — 위 도구 줄(작도 영역 · 자리표 · 단추) + 아래 웹캐드. */
.m02-drawing {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: 100%;
height: 100%;
min-height: 0;
}
.m02-drawing > .cad-host {
flex: 1 1 0;
}
.m02-drawing__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16, 16px);
}
.m02-drawing__title {
align-self: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
}
.m02-drawing__area {
display: flex;
align-items: flex-end;
gap: var(--spacing-8);
}
.m02-drawing__area .ui-field {
width: 5.5rem;
}
.m02-drawing__fields {
display: flex;
flex: 1 1 20rem;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4, 4px);
}
.m02-drawing__field {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: 1440px;
background-color: var(--color-surface);
color: var(--color-text);
font-size: 0.72rem;
cursor: pointer;
}
.m02-drawing__field[data-source="도면"] {
color: var(--color-text-muted);
}
.m02-drawing__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
@@ -0,0 +1,220 @@
/* =============================================================================
* M02_MasterTemplete_Drawing.ts
* M02 도면 양식 편집 부품 — 웹캐드를 도각 편집 모드로 띄워 양식을 만들고 고친다 (PLAN 10-3).
*
* 계약(`tmp/M02_분석/6_계약.md` 화면 부품) — `mountDrawingTemplate(칸, 문서, {onSave, readOnly})`
* → `{getDoc, destroy}`. 페이지(sub1)가 메인 칸에 붙인다.
* `getDoc()` 은 CAD iframe 에서 편집본을 받아 오므로 **Promise** 다 — `await` 로 받는다.
*
* 양식 문서 = openwebcad 도면 JSON(`entities` · `layers`) + 양식 칸(`format` · `source` ·
* `drawing_area`). CAD 는 양식 칸을 모르므로 돌려줄 때 원래 칸 위에 편집본을 얹는다.
* ========================================================================== */
import "./M02_MasterTemplete_Drawing.css";
import { API_BASE_URL } from "@config/config_frontend";
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
/** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */
export interface DrawingTemplateDoc extends CadHostDrawing {
format?: number;
source?: string;
drawing_area?: [number, number, number, number];
[key: string]: unknown;
}
export interface DrawingTemplateOptions {
/** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
readOnly?: boolean;
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
name?: string;
}
export interface DrawingTemplateHandle {
getDoc: () => Promise<DrawingTemplateDoc>;
destroy: () => void;
}
/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */
const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567];
const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"];
interface DrawingField {
key: string;
source: string;
label: string;
}
async function fetchDrawingFields(): Promise<DrawingField[]> {
const response = await fetch(`${API_BASE_URL}/m02/drawing-fields`, { credentials: "include" });
const payload = (await response.json()) as { fields?: DrawingField[]; message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload.fields ?? [];
}
async function importDrawingFile(
file: File,
): Promise<{ drawing: DrawingTemplateDoc; entity_count: number }> {
const form = new FormData();
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/m02/drawing-import`, {
method: "POST",
credentials: "include",
body: form,
});
const payload = (await response.json()) as {
drawing: DrawingTemplateDoc;
entity_count: number;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function mountDrawingTemplate(
container: HTMLElement,
doc: DrawingTemplateDoc,
options: DrawingTemplateOptions = {},
): DrawingTemplateHandle {
const readOnly = options.readOnly === true;
const recoveryScope = `m02:${options.name ?? "drawing"}`;
let base: DrawingTemplateDoc = doc;
const root = document.createElement("div");
root.className = "m02-drawing";
const toolbar = document.createElement("div");
toolbar.className = "m02-drawing__toolbar";
// 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다.
const area = document.createElement("div");
area.className = "m02-drawing__area";
const areaTitle = document.createElement("span");
areaTitle.className = "m02-drawing__title";
areaTitle.textContent = "작도 영역";
const start = doc.drawing_area ?? DEFAULT_AREA;
const areaInputs = AREA_LABELS.map((label, index) => {
const field = createInputField({ label, type: "number", value: String(start[index]) });
field.input.disabled = readOnly;
return field;
});
area.append(areaTitle, ...areaInputs.map((field) => field.root));
const readArea = (): [number, number, number, number] | null => {
const values = areaInputs.map((field) => Number(field.input.value));
const valid = values.every(Number.isFinite) && values[0] < values[2] && values[1] < values[3];
areaInputs.forEach((field) => field.setError(valid ? undefined : "왼쪽<오른쪽 · 아래<위"));
return valid ? (values as [number, number, number, number]) : null;
};
// 자리표 키 — 누르면 `{{키}}` 를 복사한다. 도각 글자에 붙여 넣으면 그릴 때 값이 채워진다.
const fields = document.createElement("div");
fields.className = "m02-drawing__fields";
const fieldsTitle = document.createElement("span");
fieldsTitle.className = "m02-drawing__title";
fieldsTitle.textContent = "자리표";
fields.append(fieldsTitle);
void fetchDrawingFields()
.then((list) => {
for (const field of list) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "m02-drawing__field";
chip.dataset.source = field.source;
chip.textContent = `{{${field.key}}}`;
chip.title = `${field.label} (${field.source}) — 눌러 복사`;
chip.addEventListener("click", () => {
void navigator.clipboard
?.writeText(`{{${field.key}}}`)
.then(() => showToast(`{{${field.key}}} 를 복사했습니다.`, "success"));
});
fields.append(chip);
}
})
.catch((error) =>
showToast(error instanceof Error ? error.message : "자리표 목록을 받지 못했습니다.", "error"),
);
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
const load = (drawing: DrawingTemplateDoc): void => {
cad.beginLoading();
cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
};
const actions = document.createElement("div");
actions.className = "m02-drawing__actions";
if (!readOnly) {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".dxf,.dwg";
fileInput.hidden = true;
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
fileInput.value = "";
if (!file) return;
importButton.disabled = true;
importDrawingFile(file)
.then((response) => {
// 불러온 도각은 아직 저장하지 않는다 — 자리표를 놓고 [저장]을 눌러야 양식이 된다.
load(response.drawing);
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
})
.catch((error) =>
showToast(
error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.",
"error",
),
)
.finally(() => (importButton.disabled = false));
});
const importButton = createButton({
label: "파일 불러오기",
variant: "ghost",
onClick: () => fileInput.click(),
});
actions.append(importButton, fileInput);
}
const getDoc = async (): Promise<DrawingTemplateDoc> => {
const { drawing } = await cad.requestSave();
const drawingArea = readArea();
if (!drawingArea) throw new Error("작도 영역 값이 올바르지 않습니다.");
base = { ...base, ...drawing, drawing_area: drawingArea };
return base;
};
if (options.onSave && !readOnly) {
const onSave = options.onSave;
const saveButton = createButton({
label: "저장",
variant: "filled",
onClick: () => {
saveButton.disabled = true;
getDoc()
.then((next) => onSave(next))
.catch((error) =>
showToast(
error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
"error",
),
)
.finally(() => (saveButton.disabled = false));
},
});
actions.append(saveButton);
}
toolbar.append(area, fields, actions);
root.append(toolbar, cad.element);
container.append(root);
load(doc);
return {
getDoc,
destroy: () => {
cad.destroy();
root.remove();
},
};
}
@@ -1,10 +1,95 @@
"""M02 마스터 템플릿 — 도면 길 틀(빈 라우터).
"""M02 도면 양식 서버 길 — 외부 도각 파일 불러오기 · 자리표 키 목록 (PLAN 10-3).
⚠ 로그인만으로 `main.py` 에 붙음 — 권한은 길 안에서 봄. 길은 계약 `6_계약.md` 를 따름.
양식을 읽고 쓰는 길은 `M02_MasterTemplete_Router.py`(시스템 층)·`_Router_Layers.py`(층)가 맡는다.
여기는 도면 양식 편집 화면만 쓰는 두 길 — B07 의 불러오기 엔진 · 표제란 값을 그대로 다시 쓴다.
"""
from __future__ import annotations
import asyncio
import logging
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Drawing"])
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_document,
validate_template_entities,
)
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
_IMPORT_MAX_BYTES = 20 * 1024 * 1024
# 자리표 `{{키}}` 로 쓸 수 있는 키 — 도면을 그릴 때 채워지는 것만 둔다.
# 프로젝트 값은 B07 `title_block_fields` 가 · 도면 값은 도면을 그리는 엔진이 채운다.
DRAWING_FIELDS: tuple[tuple[str, str, str], ...] = (
("공사명", "프로젝트", "프로젝트 이름"),
("위치", "프로젝트", "사업 위치"),
("시행청", "프로젝트", "발주 기관"),
("연도기번", "프로젝트", "프로젝트 번호"),
("사업량", "프로젝트", "사업량"),
("설계일자", "프로젝트", "설계 일자"),
("용역회사", "프로젝트", "회사 이름"),
("설계자", "프로젝트", "설계자 이름"),
("과업책임자", "프로젝트", "과업책임자 이름"),
("분야별책임자", "프로젝트", "분야별책임자 이름"),
("회사로고", "프로젝트", "회사 로고 그림"),
("설계자서명", "프로젝트", "설계자 서명 그림"),
("과업책임자서명", "프로젝트", "과업책임자 서명 그림"),
("분야별책임자서명", "프로젝트", "분야별책임자 서명 그림"),
("도면명", "도면", "도면 이름"),
("도면번호", "도면", "도면 목록 순번"),
("축척_A1", "도면", "A1 축척 분모"),
("축척_A3", "도면", "A3 축척 분모"),
)
@router.post("/drawing-import")
async def import_drawing_file(file: UploadFile = File(...)) -> JSONResponse:
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다 — 저장하지 않는다."""
try:
data = await file.read()
if len(data) > _IMPORT_MAX_BYTES:
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
validate_template_entities(entities)
return JSONResponse(
{
"status": "success",
"drawing": frame_document(entities),
"entity_count": len(entities),
}
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("M02 도각 불러오기 실패: %s", file.filename)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
)
@router.get("/drawing-fields")
async def get_drawing_fields(project_id: UUID | None = None) -> JSONResponse:
"""자리표 키 목록. project_id 를 주면 그 프로젝트의 표제란 값도 함께(미리보기용)."""
try:
values = await title_block_fields(project_id) if project_id else {}
except Exception:
logger.exception("M02 자리표 값 조회 실패: project_id=%s", project_id)
values = {}
return JSONResponse(
{
"status": "success",
"fields": [
{"key": key, "source": source, "label": label}
for key, source, label in DRAWING_FIELDS
],
"values": values,
}
)
@@ -1,10 +1,437 @@
"""M02 마스터 템플릿 — 층 길 틀(빈 라우터).
"""M02 양식 층 API — 계약 `tmp/M02_분석/6_계약.md` 「층 (sub4)」.
⚠ 로그인만으로 `main.py` 에 붙음 — 권한은 길 안에서 봄. 길은 계약 `6_계약.md` 를 따름.
⚠ 등록은 `main.py`(로그인만) · 층마다 권한은 여기서:
system 읽기만(고치기는 `/api/m02/templates` 시스템 관리자 길)
company 같은 회사 읽기 · 쓰기는 회사 관리자(ADMIN · 마스터 · 시스템 관리자)
personal 본인 읽기·쓰기 · 같은 회사 사람 것은 읽기만(가져오기)
project 같은 회사 프로젝트 · 작업본 쓰기 · `_initial/` 은 안 씀
"""
from __future__ import annotations
from fastapi import APIRouter
import asyncio
import logging
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_workflow_route_context,
)
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from M02_MasterTemplete import M02_Table_Fill as fill
from M02_MasterTemplete import M02_Template_Layers as layers
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Layers"])
Layer = Literal["system", "company", "personal", "project"]
class SaveBody(BaseModel):
판: str | None = None
문서: dict[str, Any]
class TargetBody(BaseModel):
종류: str | None = None
이름: str | None = None
class ApplyBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
from_: Layer = Field(alias="from")
user_id: int | None = None
project_id: str | None = None
종류: str | None = None
이름: str | None = None
class SaveAsBody(BaseModel):
to: Literal["personal", "company"]
종류: str
이름: str
# ── DB (시험이 바꿔 끼움) ─────────────────────────────
async def _project_row(project_id: str) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""SELECT id, name, company_id, user_id, storage_path FROM projects
WHERE id = %s AND deleted_at IS NULL""",
(str(project_id),),
)
row = await cursor.fetchone()
if not row:
return None
keys = ("id", "name", "company_id", "user_id", "storage_path")
return dict(zip(keys, row, strict=True))
async def _user_company(user_id: int) -> int | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL", (user_id,)
)
row = await cursor.fetchone()
return row[0] if row else None
async def _company_users(company_id: int) -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT id, name FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY id",
(company_id,),
)
rows = await cursor.fetchall()
return [{"user_id": row[0], "name": row[1]} for row in rows]
async def _company_projects(company_id: int) -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""SELECT id, name, storage_path FROM projects
WHERE company_id = %s AND deleted_at IS NULL ORDER BY created_at DESC""",
(company_id,),
)
rows = await cursor.fetchall()
return [{"project_id": str(row[0]), "name": row[1], "storage_path": row[2]} for row in rows]
async def _cross_designs(project_id: str) -> list[dict[str, Any]]:
"""최신 노선의 B06 횡단 설계 — 못 읽으면 빈 목록(관 연장이 빈칸으로 섬 · 0 아님)."""
try:
context = await run_with_connection(get_workflow_route_context, UUID(str(project_id)))
route_id = int((context or {}).get("route_id") or 0)
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
except Exception:
logger.exception("M02 채운 표 — 횡단 설계 조회 실패: project_id=%s", project_id)
return []
# ── 권한 · 자리 ───────────────────────────────────────
def _is_system_admin(session: dict[str, Any]) -> bool:
return session.get("role") == "SYSTEM_ADMIN"
def _is_company_admin(session: dict[str, Any]) -> bool:
return session.get("role") in ("ADMIN", "SYSTEM_ADMIN") or bool(session.get("is_master"))
async def _project(session: dict[str, Any], project_id: str | None) -> dict[str, Any]:
"""같은 회사 프로젝트 한 건 + `root`(실경로). 아니면 403/404."""
if not project_id:
raise HTTPException(status_code=400, detail="project_id 가 필요합니다.")
row = await _project_row(project_id)
if row is None:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if not _is_system_admin(session) and row["company_id"] != session.get("company_id"):
raise HTTPException(status_code=403, detail="다른 회사의 프로젝트입니다.")
if not row.get("storage_path"):
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
row["root"] = Path(resolve_stored_project_path(row["storage_path"]))
return row
def _company_of(session: dict[str, Any], project: dict[str, Any] | None) -> int:
company_id = project["company_id"] if project else session.get("company_id")
if company_id is None:
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
return int(company_id)
async def _same_company_user(session: dict[str, Any], user_id: int, company_id: int) -> None:
if user_id == session.get("user_id"):
return
if await _user_company(user_id) != company_id:
raise HTTPException(status_code=403, detail="같은 회사 사람의 양식만 볼 수 있습니다.")
async def _layer_dir(
session: dict[str, Any],
layer: str,
*,
project_id: str | None,
user_id: int | None = None,
write: bool = False,
) -> Path:
"""층 폴더 — 읽기·쓰기 권한까지 여기서 가름."""
if layer == "system":
if write:
raise HTTPException(status_code=403, detail="시스템 양식은 마스터 템플릿 화면에서만.")
return layers.system_dir()
if layer == "project":
return layers.project_dir((await _project(session, project_id))["root"])
project = await _project(session, project_id) if project_id else None
company_id = _company_of(session, project)
if layer == "company":
if write and not _is_company_admin(session):
raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.")
return layers.company_dir(company_id)
if layer == "personal":
owner = int(user_id) if user_id is not None else int(session["user_id"])
if write and owner != session.get("user_id"):
raise HTTPException(status_code=403, detail="개인 양식은 본인만 고칩니다.")
await _same_company_user(session, owner, company_id)
return layers.personal_dir(company_id, owner)
raise HTTPException(status_code=404, detail="없는 층입니다.")
def _bad(error: ValueError) -> HTTPException:
return HTTPException(status_code=400, detail=str(error))
# ── 층 길 ─────────────────────────────────────────────
@router.get("/layers/{layer}/templates")
async def list_layer(
layer: Layer,
project_id: str | None = Query(None),
user_id: int | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id)
rows = await asyncio.to_thread(layers.list_templates, folder)
manifest = await asyncio.to_thread(layers.read_manifest, folder)
for row in rows:
row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}")
return {"층": layer, "양식": rows}
@router.get("/layers/{layer}/templates/{kind}/{name}")
async def read_layer_template(
layer: Layer,
kind: str,
name: str,
project_id: str | None = Query(None),
user_id: int | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, user_id=user_id)
try:
found = await asyncio.to_thread(layers.read_template, folder, kind, name)
except ValueError as error:
raise _bad(error) from error
if found is None and layer == "project":
# 옛 프로젝트(사본 없음) — 시스템 양식으로 떨어짐 · 저장하면 그때 작업본이 생김
found = await asyncio.to_thread(layers.read_template, layers.system_dir(), kind, name)
if found is not None:
found.update({"층": "system", "판": None})
return found
if found is None:
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
found["층"] = layer
found["출처"] = layers.read_manifest(folder).get(f"{kind}/{name}")
return found
@router.put("/layers/{layer}/templates/{kind}/{name}")
async def save_layer_template(
layer: Layer,
kind: str,
name: str,
body: SaveBody,
project_id: str | None = Query(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
try:
version = await asyncio.to_thread(
layers.write_template,
folder,
kind,
name,
body.문서,
version=body.판,
check_version=True,
)
except layers.StaleTemplate as error:
raise HTTPException(
status_code=409, detail={"message": str(error), "판": error.current}
) from error
except ValueError as error:
raise _bad(error) from error
return {"종류": kind, "이름": name, "판": version, "층": layer}
# ── 프로젝트 길 ───────────────────────────────────────
@router.post("/projects/{project_id}/templates/reset")
async def reset_project_templates(
project_id: str,
body: TargetBody | None = None,
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
project = await _project(session, project_id)
target = body or TargetBody()
try:
done = await asyncio.to_thread(
layers.reset_project, project["root"], kind=target.종류, name=target.이름
)
except ValueError as error:
raise _bad(error) from error
return {"초기화": done}
@router.post("/projects/{project_id}/templates/apply")
async def apply_project_templates(
project_id: str,
body: ApplyBody,
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""다른 층 양식을 작업본에 덮어씀 — [회사 양식 적용] · [양식 가져오기].
`_initial/` 은 그대로 — 초기화 기준은 안 바뀜.
"""
project = await _project(session, project_id)
ref: dict[str, Any] = {}
if body.from_ == "project":
if not body.project_id or body.project_id == project_id:
raise HTTPException(status_code=400, detail="가져올 다른 프로젝트를 고르세요.")
other = await _project(session, body.project_id)
if other["company_id"] != project["company_id"]:
raise HTTPException(status_code=403, detail="같은 회사 프로젝트만 가져옵니다.")
source = layers.project_dir(other["root"])
ref = {"project_id": body.project_id, "프로젝트": other.get("name")}
elif body.from_ == "personal":
owner = body.user_id if body.user_id is not None else int(session["user_id"])
await _same_company_user(session, owner, int(project["company_id"]))
source = layers.personal_dir(project["company_id"], owner)
ref = {"user_id": owner}
elif body.from_ == "company":
source = layers.company_dir(project["company_id"])
else:
source = layers.system_dir()
try:
done = await asyncio.to_thread(
layers.copy_templates,
source,
layers.project_dir(project["root"]),
source_layer=body.from_,
kind=body.종류,
name=body.이름,
source_ref=ref,
)
except ValueError as error:
raise _bad(error) from error
if not done:
raise HTTPException(status_code=404, detail="가져올 양식이 없습니다.")
return {"적용": done, "from": body.from_}
@router.post("/projects/{project_id}/templates/save-as")
async def save_project_template_as(
project_id: str,
body: SaveAsBody,
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""작업본 한 벌을 [내 양식으로 저장] · [회사 공식으로 저장]."""
project = await _project(session, project_id)
if body.to == "company" and not _is_company_admin(session):
raise HTTPException(status_code=403, detail="회사 공식 양식은 회사 관리자만.")
if body.to == "company":
target = layers.company_dir(project["company_id"])
else:
target = layers.personal_dir(project["company_id"], session["user_id"])
try:
done = await asyncio.to_thread(
layers.copy_templates,
layers.project_dir(project["root"]),
target,
source_layer="project",
kind=body.종류,
name=body.이름,
source_ref={"project_id": project_id, "프로젝트": project.get("name")},
)
except ValueError as error:
raise _bad(error) from error
if not done:
raise HTTPException(status_code=404, detail="프로젝트 작업본에 그 양식이 없습니다.")
return {"저장": done, "to": body.to}
@router.get("/projects/{project_id}/sources")
async def list_sources(
project_id: str,
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""가져올 수 있는 것 — 시스템 · 회사 공식 · 같은 회사 사람 개인 · 같은 회사 다른 프로젝트."""
project = await _project(session, project_id)
company_id = int(project["company_id"])
def _people(users: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows = []
for user in users:
found = layers.list_templates(layers.personal_dir(company_id, user["user_id"]))
if found:
rows.append({**user, "양식": found})
return rows
def _projects(projects: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows = []
for other in projects:
if other["project_id"] == str(project_id) or not other.get("storage_path"):
continue
try:
root = Path(resolve_stored_project_path(other["storage_path"]))
except ValueError:
continue
found = layers.list_templates(layers.project_dir(root))
if found:
rows.append(
{"project_id": other["project_id"], "name": other["name"], "양식": found}
)
return rows
users = await _company_users(company_id)
projects = await _company_projects(company_id)
return {
"system": await asyncio.to_thread(layers.list_templates, layers.system_dir()),
"company": await asyncio.to_thread(layers.list_templates, layers.company_dir(company_id)),
"personal": await asyncio.to_thread(_people, users),
"project": await asyncio.to_thread(_projects, projects),
}
@router.get("/projects/{project_id}/tables/{name}/filled")
async def filled_table(
project_id: str,
name: str,
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""설계값을 채운 표 문서 — 저장 안 함(5장 ③ 서버 단독) · `결과` = 계산 열(없으면 null)."""
project = await _project(session, project_id)
try:
found = await asyncio.to_thread(
layers.read_template, layers.project_dir(project["root"]), "table", name
)
if found is None:
found = await asyncio.to_thread(
layers.read_template, layers.system_dir(), "table", name
)
except ValueError as error:
raise _bad(error) from error
if found is None:
raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.")
lengths = fill.pipe_lengths_from_designs(await _cross_designs(project_id))
document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"], lengths)
result = await asyncio.to_thread(fill.recalc, document)
return {"이름": name, "판": found["판"], "문서": document, "결과": result}
+312
View File
@@ -0,0 +1,312 @@
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
층 넷 (PLAN 10-5):
system `resources/master_template/{table,drawing}/<이름>.json` (git)
company `storage/{회사}/templates/{table,drawing}/`
personal `storage/{회사}/{사용자}/templates/{table,drawing}/`
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing}/`
+ 초기 사본 `templates/_initial/`
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
- 판 = 파일 sha256 앞 16자(M01 Store 와 같음) · 판이 다르면 `StaleTemplate`.
- `_initial/` 은 프로젝트 첫 복사 때만 씀 — 그 뒤 어떤 길로도 쓰지 않음(초기화는 읽기만).
- 권한은 부르는 쪽(라우터) 몫 — 여기는 자리와 파일만.
"""
from __future__ import annotations
import hashlib
import json
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from config import config_system
LAYERS = ("system", "company", "personal", "project")
KINDS = ("table", "drawing")
TEMPLATES_DIRNAME = "templates"
INITIAL_DIRNAME = "_initial"
MANIFEST_NAME = "manifest.json"
#: 시스템 층 뿌리 — 시험이 임시 폴더로 바꿈.
SYSTEM_ROOT = config_system.PROJECT_ROOT / "resources" / "master_template"
class StaleTemplate(Exception):
"""저장하려는 판이 파일의 지금 판과 다름 — 라우터가 409 로 답함."""
def __init__(self, current: str | None) -> None:
super().__init__("양식이 그새 바뀌었습니다.")
self.current = current
# ── 자리 ──────────────────────────────────────────────
def storage_root() -> Path:
"""`storage/` 실경로 — 부를 때마다 설정을 읽음(시험이 바꿈)."""
return Path(config_system.STORAGE_BASE_DIR).resolve()
def _inside(root: Path, path: Path) -> Path:
path = path.resolve()
if root.resolve() not in (path, *path.parents):
raise ValueError("양식 자리가 저장소 루트를 벗어났습니다.")
return path
def _id(value: Any, what: str) -> str:
text = str(value)
if not text.isdigit():
raise ValueError(f"{what} 식별자가 올바르지 않습니다.")
return text
def system_dir() -> Path:
return Path(SYSTEM_ROOT)
def company_dir(company_id: int | str) -> Path:
root = storage_root()
return _inside(root, root / _id(company_id, "회사") / TEMPLATES_DIRNAME)
def personal_dir(company_id: int | str, user_id: int | str) -> Path:
root = storage_root()
return _inside(
root, root / _id(company_id, "회사") / _id(user_id, "사용자") / TEMPLATES_DIRNAME
)
def project_dir(project_root: str | Path) -> Path:
"""프로젝트 작업본 자리 — `project_root` 는 `resolve_stored_project_path` 결과."""
return Path(project_root) / TEMPLATES_DIRNAME
def initial_dir(project_root: str | Path) -> Path:
return project_dir(project_root) / INITIAL_DIRNAME
def check_kind(kind: str) -> str:
if kind not in KINDS:
raise ValueError(f"양식 종류는 {', '.join(KINDS)} 중 하나입니다.")
return kind
def check_name(name: str) -> str:
text = str(name or "").strip()
bad = not text or text != name or text.startswith((".", "_")) or len(text) > 80
if bad or any(ch in text for ch in '/\\:*?"<>|') or ".." in text:
raise ValueError("양식 이름이 올바르지 않습니다.")
return text
def template_path(layer_dir: str | Path, kind: str, name: str) -> Path:
base = Path(layer_dir)
return _inside(base, base / check_kind(kind) / f"{check_name(name)}.json")
# ── 읽기 · 쓰기 ───────────────────────────────────────
def version_of(path: str | Path) -> str | None:
path = Path(path)
if not path.is_file():
return None
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
def _modified(path: Path) -> str:
return datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds")
def list_templates(layer_dir: str | Path) -> list[dict[str, Any]]:
"""층 하나의 양식 목록 `[{종류, 이름, 판, 수정일}]` — 종류 · 이름 차례."""
base = Path(layer_dir)
rows: list[dict[str, Any]] = []
for kind in KINDS:
folder = base / kind
if not folder.is_dir():
continue
for path in sorted(folder.glob("*.json")):
if path.name.startswith((".", "_")):
continue
rows.append(
{
"종류": kind,
"이름": path.stem,
"판": version_of(path),
"수정일": _modified(path),
}
)
return rows
def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any] | None:
"""`{종류, 이름, 판, 문서}` — 없으면 None."""
path = template_path(layer_dir, kind, name)
if not path.is_file():
return None
raw = path.read_bytes()
return {
"종류": kind,
"이름": name,
"판": hashlib.sha256(raw).hexdigest()[:16],
"문서": json.loads(raw.decode("utf-8")),
}
def write_template(
layer_dir: str | Path,
kind: str,
name: str,
document: dict[str, Any],
*,
version: str | None = None,
check_version: bool = False,
) -> str:
"""원자 쓰기 → 새 판. `check_version` 이면 `version` 이 지금 판과 같아야 함(새 파일은 None)."""
if not isinstance(document, dict):
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
path = template_path(layer_dir, kind, name)
if check_version:
current = version_of(path)
if current != (version or None):
raise StaleTemplate(current)
atomic_write_json(path, document)
return version_of(path) or ""
def delete_template(layer_dir: str | Path, kind: str, name: str) -> bool:
path = template_path(layer_dir, kind, name)
if not path.is_file():
return False
path.unlink()
manifest = read_manifest(layer_dir)
if manifest.pop(f"{kind}/{name}", None) is not None:
_write_manifest(layer_dir, manifest)
return True
# ── manifest ──────────────────────────────────────────
def read_manifest(layer_dir: str | Path) -> dict[str, Any]:
path = Path(layer_dir) / MANIFEST_NAME
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data.get("양식", {}) if isinstance(data, dict) else {}
def _write_manifest(layer_dir: str | Path, entries: dict[str, Any]) -> None:
atomic_write_json(Path(layer_dir) / MANIFEST_NAME, {"양식": entries})
def _stamp(layer: str, name: str, version: str | None, **extra: Any) -> dict[str, Any]:
entry = {
"층": layer,
"이름": name,
"판": version,
"적용일": datetime.now().isoformat(timespec="seconds"),
}
entry.update({key: value for key, value in extra.items() if value is not None})
return entry
# ── 복사 ──────────────────────────────────────────────
def copy_templates(
source_dir: str | Path,
target_dir: str | Path,
*,
source_layer: str,
kind: str | None = None,
name: str | None = None,
only_missing: bool = False,
source_ref: dict[str, Any] | None = None,
) -> list[str]:
"""`source_dir` 층의 양식을 `target_dir` 로 복사 → 복사한 `종류/이름` 목록.
`kind`·`name` 으로 좁힘 · `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만).
manifest 에 출처(층 · 이름 · 판 · `source_ref`)를 적음. 출처 manifest 의 원래 출처는
적지 않음 — 「어디서 가져왔나」 한 단계만.
"""
if source_layer not in LAYERS:
raise ValueError("출처 층이 올바르지 않습니다.")
rows = list_templates(source_dir)
if kind is not None:
rows = [row for row in rows if row["종류"] == check_kind(kind)]
if name is not None:
rows = [row for row in rows if row["이름"] == check_name(name)]
manifest = read_manifest(target_dir)
copied: list[str] = []
for row in rows:
source = template_path(source_dir, row["종류"], row["이름"])
target = template_path(target_dir, row["종류"], row["이름"])
if only_missing and target.exists():
continue
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(f".{target.name}.tmp")
shutil.copyfile(source, temporary)
temporary.replace(target)
key = f"{row['종류']}/{row['이름']}"
manifest[key] = _stamp(source_layer, row["이름"], row["판"], **(source_ref or {}))
copied.append(key)
if copied:
_write_manifest(target_dir, manifest)
return copied
def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dict[str, list[str]]:
"""시스템 양식 전부를 프로젝트 작업본과 `_initial/` 로 복사.
프로젝트 만들 때 · 옛 프로젝트 넣기(`only_missing`) 둘 다 이 길.
`_initial/` 은 늘 없는 것만 더함 — 이미 있으면 절대 덮지 않음.
"""
return {
"작업본": copy_templates(
system_dir(),
project_dir(project_root),
source_layer="system",
only_missing=only_missing,
),
"초기": copy_templates(
system_dir(), initial_dir(project_root), source_layer="system", only_missing=True
),
}
def reset_project(
project_root: str | Path, *, kind: str | None = None, name: str | None = None
) -> list[str]:
"""[초기화] — `_initial/` 을 작업본에 덮어씀(`_initial/` 은 읽기만)."""
initial = initial_dir(project_root)
rows = list_templates(initial)
if kind is not None:
rows = [row for row in rows if row["종류"] == check_kind(kind)]
if name is not None:
rows = [row for row in rows if row["이름"] == check_name(name)]
initial_manifest = read_manifest(initial)
manifest = read_manifest(project_dir(project_root))
done: list[str] = []
for row in rows:
key = f"{row['종류']}/{row['이름']}"
target = template_path(project_dir(project_root), row["종류"], row["이름"])
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(f".{target.name}.tmp")
shutil.copyfile(template_path(initial, row["종류"], row["이름"]), temporary)
temporary.replace(target)
manifest[key] = initial_manifest.get(key) or _stamp("system", row["이름"], row["판"])
done.append(key)
if done:
_write_manifest(project_dir(project_root), manifest)
return done
@@ -1,6 +1,7 @@
{
"format": 6,
"source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)",
"drawing_area": [42, 47, 812, 567],
"entities": [
{
"id": "4afa84ae-9c15-50ec-8a76-db87d04d6311",
@@ -0,0 +1,228 @@
{
"양식": "구조물집계표",
"종류": "표",
"판": 1,
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값)",
"층": ["종류", "공법", "규격"],
"변수": {
"파형강관_1본_m": 8,
"흄관_1본_m": "",
"VR관_1본_m": "",
"수축줄눈_간격_m": 6,
"측점간격_m": 20
},
"쪽줄": 50,
"열": [
{ "id": "no", "머리": ["NO", null, null], "단위": null, "꼴": "글", "설명": "줄 차례(전구간 줄 빼고 1부터)" },
{ "id": "sta", "머리": ["측점", null, null], "단위": null, "꼴": "글", "설명": "점 NO.x+y · 구간 NO.a~NO.b · 맨 위 전구간" },
{
"id": "rv", "머리": ["돌기슭막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 기슭막이 · 관 유입·유출 기슭막이 길이 · 형태 → 높이마다 열",
"일위대가": "UA000004",
"바인딩": {
"종류": "revetment", "펼침": ["form", "height_m"], "값": "length_m", "묶음": "rv",
"머리틀": ["돌기슭막이", "{0}", "H={1}"],
"이름": { "돌쌓기(찰)": "찰쌓기", "돌쌓기(메)": "메쌓기" },
"더함": [
{ "종류": "pipe", "펼침": ["inlet_revet_form", "inlet_revet_height_m"], "값": "inlet_revet_length_m", "조건": { "inlet_type": "기슭막이" } },
{ "종류": "pipe", "펼침": ["outlet_revet_form", "outlet_revet_height_m"], "값": "outlet_revet_length_m", "조건": { "outlet_type": "기슭막이" } }
]
}
},
{
"id": "pv_b", "머리": ["콘크리트포장", "T=(두께마다)", "B"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 폭",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "width_m", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "B"] }
},
{
"id": "pv_l", "머리": ["콘크리트포장", "T=(두께마다)", "L"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 길이",
"일위대가": "UA000007",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "길이", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "L"] }
},
{
"id": "pv_w", "머리": ["콘크리트포장", "T=(두께마다)", "확폭"], "단위": "㎡", "꼴": "수", "펼침": true,
"설명": "설계값 — 확폭 면적",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "widening_area_m2", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "확폭"] }
},
{
"id": "pv_a", "머리": ["콘크리트포장", "T=(두께마다)", "A"], "단위": "㎡", "꼴": "수", "펼침": true,
"식": "IF([pv_l]>0,[pv_b]*[pv_l]+[pv_w],\"\")",
"끝수": { "자리": 2, "방법": "반올림" },
"설명": "계산 — B × L + 확폭",
"일위대가": "UA000006",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "A"] }
},
{
"id": "pv_jt", "머리": ["콘크리트포장", "T=(두께마다)", "수축줄눈"], "단위": "m", "꼴": "수", "펼침": true,
"식": "IF([pv_l]>0,INT([pv_l]/[$수축줄눈_간격_m])*[pv_b],\"\")",
"끝수": { "자리": 2, "방법": "반올림" },
"설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)",
"바인딩": { "종류": "pavement_concrete", "펼침": ["thickness_cm"], "값": "식", "묶음": "pv", "머리틀": ["콘크리트포장", "T={0}cm", "수축줄눈"] }
},
{
"id": "pp_len", "머리": ["관공", "(관종·관경마다)", "관매설"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔",
"일위대가": "UA000036",
"일위대가후보": ["UA000036", "UA000027"],
"바인딩": { "종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "관연장", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "관매설"] }
},
{
"id": "pp_cp", "머리": ["관공", "(관종·관경마다)", "커플링밴드"], "단위": "개소", "꼴": "수", "펼침": true,
"식": "IF([pp_len]>0,ROUNDUP([pp_len]/[$파형강관_1본_m],0)-1,\"\")",
"설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)",
"바인딩": {
"종류": "pipe", "펼침": ["pipe_kind", "pipe_diameter_mm"], "값": "식", "묶음": "pp", "머리틀": ["관공", "{0} Φ{1}", "커플링밴드"],
"식틀": "IF([pp_len]>0,ROUNDUP([pp_len]/[${0}_1본_m],0)-1,\"\")"
}
},
{
"id": "pg_in", "머리": ["관보호공", "(형식마다)", "유입"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 관 날개벽 형식 · 유입구가 집수정이면 집수정 형식 · 형식마다 열(유출구는 기슭막이 — 돌기슭막이 열)",
"일위대가": "UA000008",
"일위대가후보": ["UA000008", "UA000025", "UA000029"],
"바인딩": {
"종류": "pipe", "펼침": ["wing_wall_type"], "값": "개소", "묶음": "pg", "머리틀": ["관보호공", "{0}", "유입"],
"이름": { "A-TYPE": "날개벽 A형", "C-TYPE": "날개벽 C형", "A-TYPE+집수정": "날개벽 A형+집수정" },
"더함": [ { "종류": "pipe", "펼침": ["inlet_basin_form"], "값": "개소", "조건": { "inlet_type": "집수정" } } ]
}
},
{
"id": "bx_len", "머리": ["BOX암거", "(폭×높이마다)", "연장"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — BOX 연장(계류 방향)",
"일위대가": "UA000014",
"바인딩": { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "length_m", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "연장"] }
},
{
"id": "bx_wing", "머리": ["BOX암거", "(폭×높이마다)", "날개벽"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 날개벽 유입·유출 있음 수",
"일위대가": "UA000025",
"바인딩": {
"종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "묶음": "bx", "머리틀": ["BOX암거", "{0}×{1}", "날개벽"],
"조건": { "wing_in": "있음" },
"더함": [ { "종류": "box_culvert", "펼침": ["body_width_m", "body_height_m"], "값": "개소", "조건": { "wing_out": "있음" } } ]
}
},
{
"id": "fb", "머리": ["세월교", "(관경 × 련수마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 세월교 개소",
"바인딩": { "종류": "ford_bridge", "펼침": ["pipe_diameter_mm", "pipe_count"], "값": "개소", "묶음": "fb", "머리틀": ["세월교", "Φ{0}×{1}련", null] }
},
{
"id": "fp_w", "머리": ["물넘이포장", "T=(두께마다)", "폭"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 월류 폭",
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "ford_width_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "폭"] }
},
{
"id": "fp_l", "머리": ["물넘이포장", "T=(두께마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 포장 길이(노폭 방향)",
"바인딩": { "종류": "ford_pavement", "펼침": ["thickness_cm"], "값": "length_m", "묶음": "fp", "머리틀": ["물넘이포장", "T={0}cm", "길이"] }
},
{
"id": "od_len", "머리": ["횡단개거", "(규격마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 개거 연장",
"일위대가": "UA000026",
"바인딩": { "종류": "open_ditch", "펼침": ["ditch_spec"], "값": "length_m", "묶음": "od", "머리틀": ["횡단개거", "{0}", "길이"] }
},
{
"id": "od_cnt", "머리": ["횡단개거", "(규격마다)", "개소"], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 개거 · 노출형 횡단수로 개소",
"일위대가": "UA000003",
"바인딩": {
"종류": "open_ditch", "펼침": ["ditch_spec"], "값": "개소", "묶음": "od", "머리틀": ["횡단개거", "{0}", "개소"],
"더함": [ { "종류": "cross_drain_exposed", "펼침": ["=노출형"], "값": "개소" } ]
}
},
{
"id": "rw", "머리": ["옹벽", "(형식마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 옹벽 길이",
"바인딩": { "종류": "retaining_wall", "펼침": ["form", "height_m"], "값": "길이", "묶음": "rw", "머리틀": ["옹벽", "{0}", "H={1}"] }
},
{
"id": "ms", "머리": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 돌쌓기 길이",
"바인딩": {
"종류": "masonry_wet", "펼침": ["=찰쌓기", "height_m"], "값": "길이", "묶음": "ms", "머리틀": ["돌쌓기", "{0}", "H={1}"],
"더함": [ { "종류": "masonry_dry", "펼침": ["=메쌓기", "height_m"], "값": "길이" } ]
}
},
{
"id": "sg", "머리": ["흙막이", "(형태마다)", "H=(높이마다)"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만",
"일위대가": "UA000032",
"바인딩": { "종류": "soil_guard", "펼침": ["form", "height_m"], "값": "길이", "묶음": "sg", "머리틀": ["흙막이", "{0}", "H={1}"] }
},
{
"id": "bm_len", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "길이"], "단위": "m", "꼴": "수", "펼침": true,
"설명": "설계값 — 큰돌쌓기 길이",
"바인딩": { "종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "길이", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "길이"] }
},
{
"id": "bm_area", "머리": ["큰돌쌓기", "(찰·메 · 돌 크기 · 높이마다)", "면적"], "단위": "㎡", "꼴": "수", "펼침": true,
"설명": "계산 — L × H (H = 펼친 높이)",
"일위대가": "UA000031",
"바인딩": {
"종류": "boulder_masonry", "펼침": ["bond", "stone_cm", "height_m"], "값": "식", "묶음": "bm", "머리틀": ["큰돌쌓기", "{0} {1} H={2}", "면적"],
"식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")"
}
},
{
"id": "ec", "머리": ["골막이", "(형태마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 골막이 개소",
"바인딩": { "종류": "erosion_check", "펼침": ["form"], "값": "개소", "묶음": "ec", "머리틀": ["골막이", "{0}", null] }
},
{
"id": "be", "머리": ["소단", null, null], "단위": "m", "꼴": "수",
"설명": "설계값 — 소단 길이",
"바인딩": { "종류": "berm", "값": "길이" }
},
{ "id": "rf", "머리": ["대피소", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 대피소", "바인딩": { "종류": "refuge", "값": "개소" } },
{ "id": "wy", "머리": ["정차·작업장", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 정차·작업장", "바인딩": { "종류": "work_yard", "값": "개소" } },
{ "id": "ta", "머리": ["차돌림곳", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 차돌림곳", "바인딩": { "종류": "turnaround", "값": "개소" } },
{ "id": "gr_rail", "머리": ["가드레일", null, null], "단위": "m", "꼴": "수", "설명": "설계값 — 가드레일 길이", "바인딩": { "종류": "guardrail", "값": "길이", "조건": { "kind": "가드레일" } } },
{ "id": "gr_curb", "머리": ["경계석", null, null], "단위": "m", "꼴": "수", "설명": "설계값 — 경계석 길이", "바인딩": { "종류": "guardrail", "값": "길이", "조건": { "kind": "경계석" } } },
{ "id": "gr_sign", "머리": ["위험표지", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 위험표지", "일위대가": "UA000017", "바인딩": { "종류": "guardrail", "값": "개소", "조건": { "kind": "위험표지" } } },
{ "id": "mr", "머리": ["반사경", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 반사경", "일위대가": "UA000018", "바인딩": { "종류": "mirror", "값": "개소" } },
{ "id": "bg", "머리": ["차단기", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 차단기", "일위대가": "UA000010", "바인딩": { "종류": "barrier_gate", "값": "개소" } },
{ "id": "ps", "머리": ["국가지점번호판", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 번호판 · 전구간 줄은 손 입력", "일위대가": "UA000016", "바인딩": { "종류": "position_sign", "값": "개소" } },
{ "id": "cs", "머리": ["준공표지석", null, null], "단위": "개소", "꼴": "수", "설명": "설계값 — 준공표지판", "일위대가": "UA000002", "바인딩": { "종류": "completion_sign", "값": "개소" } },
{
"id": "etc", "머리": ["기타", "(이름마다)", null], "단위": "개소", "꼴": "수", "펼침": true,
"설명": "설계값 — 기타 구조물 개소",
"바인딩": { "종류": "etc", "펼침": ["name"], "값": "개소", "묶음": "etc", "머리틀": ["기타", "{0}", null] }
},
{ "id": "h_intake", "머리": ["취수정", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000005" },
{ "id": "h_rip_c", "머리": ["돌붙임", "찰붙임", null], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000009" },
{ "id": "h_rip_m", "머리": ["돌붙임", "메붙임", null], "단위": "㎡", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000009" },
{ "id": "h_stone_ch", "머리": ["돌수로", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000011" },
{ "id": "h_stone_bed", "머리": ["돌조공", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000023" },
{ "id": "h_pv_rail", "머리": ["포장 난간", null, null], "단위": "m", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000012" },
{ "id": "h_pg_rail", "머리": ["관보호공 안전난간", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000013" },
{ "id": "h_soil_ditch", "머리": ["수로형 토사개거", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000033" },
{ "id": "h_ford_basin", "머리": ["물넘이 집수부", null, null], "단위": "개소", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": "UA000034" },
{ "id": "h_rockfall", "머리": ["낙석방지책", null, null], "단위": "경간", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
{ "id": "h_break", "머리": ["콘크리트·포장 깨기", null, null], "단위": "㎥", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
{ "id": "h_waste", "머리": ["폐기물처리", null, null], "단위": "ton", "꼴": "수", "손": true, "설명": "손 입력", "일위대가": null },
{ "id": "memo", "머리": ["비고", null, null], "단위": null, "꼴": "글", "손": true, "설명": "손 입력" }
],
"줄": [
{ "id": "all", "값": { "sta": "전구간" }, "고정": "전구간" }
],
"합계줄": [
{ "id": "sum", "이름": "합계", "식": "SUM" }
],
"보기": { "틀고정": { "열": 2 }, "열너비": { "no": 40, "sta": 150, "memo": 120 } }
}
+2 -4
View File
@@ -12,7 +12,7 @@ import pytest
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, FRAME_LAYER_ID
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
TEMPLATE = Path("resources/template_2dDrawing/00_template_cover.json")
TEMPLATE = Path("resources/master_template/drawing/00_template_cover.json")
# A1 도각 템플릿과 같은 재단 표식 원점 — 두 도면이 같은 종이 좌표계를 써야 한다.
PAPER = (-5.05, -5.04, 834.95, 588.96)
@@ -62,9 +62,7 @@ def test_도면은_잠금_도각_레이어로_나온다():
assert {e["layerId"] for e in drawing["entities"]} == {FRAME_LAYER_ID}
layers = {layer["id"]: layer for layer in drawing["layers"]}
assert layers[FRAME_LAYER_ID]["isLocked"] is True
assert any(not layer["isLocked"] for layer in drawing["layers"]), (
"덧그릴 비잠금 도면층이 없다"
)
assert any(not layer["isLocked"] for layer in drawing["layers"]), "덧그릴 비잠금 도면층이 없다"
@pytest.mark.parametrize(
+106
View File
@@ -0,0 +1,106 @@
"""M02 도면 양식 — 작도 영역 칸 · B07 도각 층(프로젝트 작업본 → 시스템) (PLAN 10-3)."""
import json
from B07_DesignDetail import B07_DesignDetail_Engine_Template as engine
from M02_MasterTemplete import M02_Template_Layers as layers
A1 = engine.A1_TEMPLATE
def _project_template(project_root, document):
path = engine.project_template_path(project_root)
path.parent.mkdir(parents=True)
path.write_text(json.dumps(document), encoding="utf-8")
return path
def _entities(project_root):
engine.use_project_templates(project_root)
try:
return engine.template_entities()
finally:
engine.use_project_templates(None)
def test_system_a1_reads_drawing_area():
engine.use_project_templates(None)
assert engine.drawing_area() == (42.0, 47.0, 812.0, 567.0)
assert len(engine.template_entities()) == 51
def test_missing_area_falls_back_to_a1(tmp_path):
_project_template(tmp_path, {"format": 6, "entities": [], "layers": []})
engine.use_project_templates(tmp_path)
try:
assert engine.drawing_area() == engine._A1_INNER
finally:
engine.use_project_templates(None)
def test_template_area_moves_content(tmp_path):
line = {
"id": "a",
"type": "Line",
"shapeData": {"startPoint": {"x": 0, "y": 0}, "endPoint": {"x": 10, "y": 0}},
}
_project_template(
tmp_path,
{"format": 6, "drawing_area": [0, 0, 400, 200], "entities": [line], "layers": []},
)
engine.use_project_templates(tmp_path)
try:
assert engine.drawing_area() == (0.0, 0.0, 400.0, 200.0)
assert engine.usable_area() == (400 * 0.96, 200 * 0.96)
# 콘텐츠 중심(500, 500) 이 작도 영역 중심(200, 100) 에 온다 — 이동량 (300, 400).
placed = engine.frame_entities("t", (490.0, 490.0, 510.0, 510.0), fit=False)
assert placed[0]["shapeData"]["startPoint"] == {"x": 300.0, "y": 400.0}
finally:
engine.use_project_templates(None)
def test_bad_area_falls_back(tmp_path):
_project_template(tmp_path, {"drawing_area": [10, 10, 5, 5], "entities": []})
engine.use_project_templates(tmp_path)
try:
assert engine.drawing_area() == engine._A1_INNER
finally:
engine.use_project_templates(None)
def test_old_project_reads_system(tmp_path):
# 작업본이 없는 옛 프로젝트 — 시스템 양식으로 떨어진다.
assert len(_entities(tmp_path)) == 51
assert engine.is_project_template_customized(tmp_path) is False
def test_save_reset_on_seeded_project(tmp_path):
layers.seed_project(tmp_path)
assert engine.project_template_path(tmp_path).is_file()
assert engine.is_project_template_customized(tmp_path) is False
# [완료] — 작업본에만 쓴다 · 작도 영역은 이어 받는다 · `_initial/` · 시스템은 그대로.
path = engine.save_project_template(tmp_path, [])
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["drawing_area"] == [42, 47, 812, 567]
assert _entities(tmp_path) == []
assert engine.is_project_template_customized(tmp_path) is True
initial = layers.template_path(layers.initial_dir(tmp_path), "drawing", A1)
assert len(json.loads(initial.read_text(encoding="utf-8"))["entities"]) == 51
assert (
len(json.loads(engine.system_template_path().read_text(encoding="utf-8"))["entities"]) == 51
)
# [기본 도각으로] — `_initial/` 을 작업본에 덮어쓴다.
assert engine.reset_project_template(tmp_path) is True
assert len(_entities(tmp_path)) == 51
assert engine.is_project_template_customized(tmp_path) is False
def test_reset_on_old_project_drops_to_system(tmp_path):
engine.save_project_template(tmp_path, [])
assert _entities(tmp_path) == []
assert engine.reset_project_template(tmp_path) is True
assert not engine.project_template_path(tmp_path).exists()
assert len(_entities(tmp_path)) == 51
assert engine.reset_project_template(tmp_path) is False
@@ -0,0 +1,58 @@
"""M02 도면 양식 서버 길 — 자리표 키 목록 · 도각 파일 불러오기 (PLAN 10-3)."""
import io
import json
import re
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import DRAWING_FIELDS, router
app = FastAPI()
app.include_router(router)
client = TestClient(app)
def test_fields_cover_system_placeholders():
# 시스템 도면 양식에 박힌 자리표는 전부 키 목록에 있다 — 없으면 화면이 모르는 칸이 생긴다.
keys = {key for key, _, _ in DRAWING_FIELDS}
used = set()
for path in Path("resources/master_template/drawing").glob("00_*.json"):
text = json.dumps(json.loads(path.read_text(encoding="utf-8")), ensure_ascii=False)
used |= {match.strip() for match in re.findall(r"\{\{\s*([^}]+?)\s*\}\}", text)}
assert used and used <= keys, used - keys
def test_fields_without_project():
response = client.get("/api/m02/drawing-fields")
assert response.status_code == 200
body = response.json()
assert [item["key"] for item in body["fields"]] == [key for key, _, _ in DRAWING_FIELDS]
assert body["values"] == {}
def test_import_rejects_other_files():
response = client.post(
"/api/m02/drawing-import", files={"file": ("a.txt", b"hello", "text/plain")}
)
assert response.status_code == 400
assert "DXF" in response.json()["message"]
def test_import_dxf():
import ezdxf
document = ezdxf.new()
document.modelspace().add_line((0, 0), (100, 50))
stream = io.StringIO()
document.write(stream)
response = client.post(
"/api/m02/drawing-import",
files={"file": ("frame.dxf", stream.getvalue().encode("utf-8"), "application/dxf")},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["entity_count"] == 1
assert body["drawing"]["entities"][0]["type"] == "Line"
@@ -0,0 +1,183 @@
"""M02 양식 층 — 자리 · 복사 · 초기화 · 회사 적용 · 가져오기 · 권한 (임시 storage)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util import common_util_storage
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module
from M02_MasterTemplete import M02_Template_Layers as layers
P1 = "11111111-1111-1111-1111-111111111111"
P2 = "22222222-2222-2222-2222-222222222222"
P_OTHER = "33333333-3333-3333-3333-333333333333"
@pytest.fixture
def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
monkeypatch.setattr(common_util_storage, "STORAGE_BASE_DIR", str(storage))
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path / "master_template")
layers.write_template(layers.system_dir(), "table", "구조물집계표", {"판": 1, "열": []})
layers.write_template(layers.system_dir(), "drawing", "A1_도각", {"format": 6})
projects = {
P1: {"id": P1, "name": "첫째", "company_id": 7, "user_id": 42},
P2: {"id": P2, "name": "둘째", "company_id": 7, "user_id": 43},
P_OTHER: {"id": P_OTHER, "name": "남의 회사", "company_id": 9, "user_id": 90},
}
for row in projects.values():
row["storage_path"] = f"storage/{row['company_id']}/{row['user_id']}/{row['id']}"
root = Path(common_util_storage.resolve_stored_project_path(row["storage_path"]))
layers.seed_project(root)
users = {42: 7, 43: 7, 90: 9}
async def project_row(project_id: str) -> dict[str, Any] | None:
found = projects.get(str(project_id))
return dict(found) if found else None
async def user_company(user_id: int) -> int | None:
return users.get(user_id)
async def company_users(company_id: int) -> list[dict[str, Any]]:
return [{"user_id": u, "name": f"u{u}"} for u, c in users.items() if c == company_id]
async def company_projects(company_id: int) -> list[dict[str, Any]]:
return [
{"project_id": p["id"], "name": p["name"], "storage_path": p["storage_path"]}
for p in projects.values()
if p["company_id"] == company_id
]
monkeypatch.setattr(router_module, "_project_row", project_row)
monkeypatch.setattr(router_module, "_user_company", user_company)
monkeypatch.setattr(router_module, "_company_users", company_users)
monkeypatch.setattr(router_module, "_company_projects", company_projects)
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: session
return {"client": TestClient(app), "session": session, "storage": storage}
def _root(world: dict[str, Any], project_id: str, company: int = 7, user: int = 42) -> Path:
return (world["storage"] / str(company) / str(user) / project_id).resolve()
def _url(project_id: str = P1) -> str:
return f"/api/m02/layers/project/templates/table/구조물집계표?project_id={project_id}"
def test_프로젝트_만들기_복사는_작업본과_초기본_둘에_manifest(world: dict[str, Any]) -> None:
root = _root(world, P1)
assert (root / "templates/table/구조물집계표.json").is_file()
assert (root / "templates/_initial/drawing/A1_도각.json").is_file()
entry = layers.read_manifest(root / "templates")["table/구조물집계표"]
assert entry["층"] == "system" and entry["판"] == layers.version_of(
layers.system_dir() / "table/구조물집계표.json"
)
def test_옛_프로젝트_넣기는_더하기만(world: dict[str, Any]) -> None:
root = _root(world, P1)
layers.write_template(root / "templates", "table", "구조물집계표", {"고침": True})
assert layers.seed_project(root, only_missing=True) == {"작업본": [], "초기": []}
assert layers.read_template(root / "templates", "table", "구조물집계표")["문서"] == {
"고침": True
}
def test_작업본_저장_판_다르면_409_초기화는_초기본으로(world: dict[str, Any]) -> None:
client = world["client"]
got = client.get(_url()).json()
assert got["층"] == "project" and got["문서"]["판"] == 1
saved = client.put(_url(), json={"판": got["판"], "문서": {"판": 2}})
assert saved.status_code == 200
stale = client.put(_url(), json={"판": got["판"], "문서": {"판": 3}})
assert stale.status_code == 409
reset = client.post(f"/api/m02/projects/{P1}/templates/reset", json={})
assert "table/구조물집계표" in reset.json()["초기화"]
assert client.get(_url()).json()["문서"] == {"판": 1, "열": []}
initial = _root(world, P1) / "templates/_initial/table/구조물집계표.json"
assert layers.read_template(initial.parent.parent, "table", "구조물집계표")["문서"]["판"] == 1
def test_회사_공식_저장은_관리자만_적용은_작업본만(world: dict[str, Any]) -> None:
client, session = world["client"], world["session"]
client.put(_url(), json={"판": client.get(_url()).json()["판"], "문서": {"회사": 1}})
body = {"to": "company", "종류": "table", "이름": "구조물집계표"}
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 403
session["role"] = "ADMIN"
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
applied = client.post(f"/api/m02/projects/{P2}/templates/apply", json={"from": "company"})
assert applied.status_code == 200
root2 = _root(world, P2, user=43)
assert layers.read_template(root2 / "templates", "table", "구조물집계표")["문서"] == {"회사": 1}
assert (
layers.read_template(root2 / "templates/_initial", "table", "구조물집계표")["문서"]["판"]
== 1
)
assert layers.read_manifest(root2 / "templates")["table/구조물집계표"]["층"] == "company"
def test_개인_양식은_본인만_쓰고_같은_회사는_읽어_가져옴(world: dict[str, Any]) -> None:
client, session = world["client"], world["session"]
body = {"to": "personal", "종류": "table", "이름": "구조물집계표"}
assert client.post(f"/api/m02/projects/{P1}/templates/save-as", json=body).status_code == 200
mine = f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}"
assert client.get(mine).status_code == 200
session.update(user_id=43)
theirs = mine + "&user_id=42"
assert client.get(theirs).status_code == 200
put = client.put(
f"/api/m02/layers/personal/templates/table/구조물집계표?project_id={P1}&user_id=42",
json={"판": None, "문서": {}},
)
assert put.status_code == 200 # user_id 는 쓰기에서 무시 — 본인(43) 자리에 새로 씀
assert (world["storage"] / "7/43/templates/table/구조물집계표.json").is_file()
got = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 42}
)
assert got.status_code == 200
assert client.get(mine.replace(P1, P_OTHER)).status_code == 403
far = client.post(
f"/api/m02/projects/{P2}/templates/apply", json={"from": "personal", "user_id": 90}
)
assert far.status_code == 403
def test_가져오기는_같은_회사_프로젝트만_목록도(world: dict[str, Any]) -> None:
client = world["client"]
ok = client.post(
f"/api/m02/projects/{P1}/templates/apply", json={"from": "project", "project_id": P2}
)
assert ok.status_code == 200
bad = client.post(
f"/api/m02/projects/{P1}/templates/apply", json={"from": "project", "project_id": P_OTHER}
)
assert bad.status_code == 403
sources = client.get(f"/api/m02/projects/{P1}/sources").json()
assert [row["project_id"] for row in sources["project"]] == [P2]
assert {row["이름"] for row in sources["system"]} == {"구조물집계표", "A1_도각"}
def test_시스템_층은_읽기만_이름은_막음(world: dict[str, Any]) -> None:
client = world["client"]
assert client.get("/api/m02/layers/system/templates").json()["양식"]
put = client.put(
"/api/m02/layers/system/templates/table/구조물집계표", json={"판": None, "문서": {}}
)
assert put.status_code == 403
for name in ("..", "_initial", "a.b/c"):
with pytest.raises(ValueError):
layers.template_path(layers.system_dir(), "table", name)
+104
View File
@@ -0,0 +1,104 @@
/* ui_template/cad_host — 웹캐드 iframe 칸 · 도각 편집 띠 (B07 · M02 공용). */
/* CAD 뷰어 호스트 (상세 페이지 영역) */
.cad-host {
position: relative;
width: 100%;
height: 100%;
min-height: 420px;
overflow: hidden;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
/* 도면을 받는 동안 띄우는 표시. 이게 없으면 유역도처럼 2~3초 걸리는 도면에서
화면이 멎은 것처럼 보이고, 실패해도 이전 도면이 남아 사용자가 모른다
(2026-09-01 실측 — 코드는 data-loading을 붙이는데 받는 규칙이 없었다). */
.cad-host[data-loading="true"]::after {
content: "도면을 불러오는 중…";
position: absolute;
z-index: 4;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 10px 18px;
border-radius: var(--radius-cards);
background-color: var(--color-surface);
box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
color: var(--color-text);
font-size: 13px;
}
.cad-host[data-error]:not([data-error=""])::before {
content: attr(data-error);
position: absolute;
z-index: 5;
top: 12px;
left: 50%;
transform: translateX(-50%);
max-width: 70%;
padding: 8px 16px;
border: 1px solid var(--color-danger, #d33);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
color: var(--color-danger, #d33);
font-size: 12px;
}
/* B07 독립형 CAD 앱 임베드 */
.cad-host__frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.cad-host__license {
position: absolute;
z-index: 2;
right: 10px;
bottom: 7px;
color: color-mix(in srgb, var(--color-text-muted) 55%, transparent);
font-size: 9px;
line-height: 1;
text-decoration: none;
}
.cad-host__license:hover {
color: var(--color-text-muted);
text-decoration: underline;
}
/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과
겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */
.cad-frame-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background-color: var(--color-surface);
}
.cad-frame-edit[hidden] {
display: none;
}
.cad-frame-edit__label {
font-size: 0.78rem;
line-height: 1.4;
color: var(--color-text);
}
.cad-frame-edit__buttons {
display: flex;
gap: var(--spacing-8);
}
.cad-frame-edit__buttons > button {
flex: 1 1 0;
min-width: 0;
padding: 4px 8px;
font-size: 0.78rem;
}
+258
View File
@@ -0,0 +1,258 @@
/* =============================================================================
* ui_template/cad_host/cad_host.ts
* 웹캐드(openwebcad) iframe 부모 쪽 한 벌 — B07 상세 설계 · M02 도면 양식이 함께 쓴다.
*
* iframe 띄우기 · ready 대기 · 시간초과 · 도면 싣기 · 저장 요청 · 토스트 중계를 맡는다.
* 페이지마다 다른 일(도면 넘기기 · 내보내기 · 미저장 표시)은 콜백으로 받는다.
* CAD 앱 쪽 짝은 `B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts`.
* ========================================================================== */
import "./cad_host.css";
import { showToast } from "@ui/ui_template_elements";
/** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
const CAD_APP_URL = "/b07-cad/index.html";
const CAD_LOAD_MESSAGE = "aislo:b08:load-drawing";
const CAD_READY_MESSAGE = "aislo:b08:drawing-ready";
const CAD_LOADED_MESSAGE = "aislo:b08:drawing-loaded";
const CAD_ERROR_MESSAGE = "aislo:b08:drawing-error";
const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed";
const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request";
const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response";
const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
const CAD_EXPORT_MESSAGE = "aislo:b08:export-file";
/** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시).
* CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
/** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */
export interface CadHostDrawing {
entities: Record<string, unknown>[];
layers?: Record<string, unknown>[];
}
/** CAD 저장 응답 (도면 + 수량표 — 수량표는 B07 횡단도만). */
export interface CadSaveResult<D, Q> {
drawing: D;
quantityTable: Q | null;
}
export interface CadHostOptions {
/** iframe 제목(접근성). */
title: string;
/** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */
onChanged?: (dirty: boolean) => void;
/** CAD 리본의 이전·다음 도면 단추. */
onNavigate?: (direction: "prev" | "next") => void;
/** CAD 리본의 DXF·DWG 내보내기 단추. */
onExport?: (fileFormat: "dxf" | "dwg") => void;
}
/** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */
export interface CadLoadExtra {
/** 자동백업 칸 이름 — 없으면 meta.drawingId. 칸이 겹치면 백업을 서로 덮는다. */
recoveryScope?: string;
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
readOnly?: boolean;
}
export interface CadHost<D extends CadHostDrawing, M, Q> {
/** iframe 과 라이선스 글을 담은 칸 — 페이지 메인 칸에 붙인다. */
element: HTMLElement;
/** 「불러오는 중」을 켜고 시계를 건다. 끝은 CAD 의 loaded·error 통지가 알린다. */
beginLoading: () => void;
/** 불러오기 실패 — 시계를 끊고 실패 표시 · 토스트. */
fail: (detail: string) => void;
/** 도면을 싣는다 — CAD 가 아직 안 떴으면 ready 때 보낸다.
* frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
* (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). */
load: (
drawing: D,
meta: M | null,
frameEdit?: boolean,
frameFields?: Record<string, string>,
extra?: CadLoadExtra,
) => void;
/** CAD 의 지금 편집본을 받는다. */
requestSave: () => Promise<CadSaveResult<D, Q>>;
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
destroy: () => void;
}
export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown>(
options: CadHostOptions,
): CadHost<D, M, Q> {
const element = document.createElement("div");
element.className = "cad-host";
const frame = document.createElement("iframe");
frame.className = "cad-host__frame";
frame.src = CAD_APP_URL;
frame.title = options.title;
const license = document.createElement("a");
license.className = "cad-host__license";
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
license.target = "_blank";
license.rel = "noreferrer";
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
element.append(frame, license);
let cadReady = false;
let pendingLoad:
| {
drawing: D;
meta: M | null;
frameEdit: boolean;
frameFields: Record<string, string>;
extra: CadLoadExtra;
}
| undefined;
let resolveSave: ((payload: CadSaveResult<D, Q>) => void) | undefined;
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
const CAD_READY_TIMEOUT_MS = 20000;
const CAD_LOAD_TIMEOUT_MS = 15000;
let loadWatchdog: number | undefined;
const showFailure = (detail: string): void => {
element.dataset.loading = "false";
element.dataset.error = detail;
showToast(detail, "error");
};
const stopLoadWatchdog = (): void => {
if (loadWatchdog === undefined) return;
window.clearTimeout(loadWatchdog);
loadWatchdog = undefined;
};
const startLoadWatchdog = (): void => {
stopLoadWatchdog();
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
loadWatchdog = window.setTimeout(() => {
loadWatchdog = undefined;
if (element.dataset.loading !== "true") return;
showFailure(
cadReady
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
);
}, wait);
};
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
frame.addEventListener("error", () => {
stopLoadWatchdog();
showFailure("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
});
const post = (message: Record<string, unknown>): void => {
frame.contentWindow?.postMessage(message, window.location.origin);
};
const load: CadHost<D, M, Q>["load"] = (
drawing,
meta,
frameEdit = false,
frameFields = {},
extra = {},
) => {
pendingLoad = { drawing, meta, frameEdit, frameFields, extra };
if (!cadReady) return;
post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra });
pendingLoad = undefined;
};
const requestSave = (): Promise<CadSaveResult<D, Q>> =>
new Promise((resolve, reject) => {
resolveSave = resolve;
post({ type: CAD_SAVE_REQUEST_MESSAGE });
window.setTimeout(() => {
if (!resolveSave) return;
resolveSave = undefined;
reject(new Error("CAD 저장 응답 시간이 초과되었습니다."));
}, 5000);
});
const onMessage = (event: MessageEvent<unknown>): void => {
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
const message = event.data as {
type?: string;
detail?: string;
drawing?: D;
quantityTable?: Q | null;
direction?: "prev" | "next";
fileFormat?: "dxf" | "dwg";
dirty?: boolean;
kind?: string;
text?: string;
actionId?: string;
durationMs?: number;
};
if (message.type === CAD_TOAST_MESSAGE) {
const kind = (["info", "success", "warning", "error"] as const).find(
(item) => item === message.kind,
);
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
const actionId = message.actionId;
showToast(
message.text ?? "",
kind ?? "info",
duration,
actionId ? () => post({ type: CAD_TOAST_ACTION_MESSAGE, actionId }) : undefined,
);
} else if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad) {
const { drawing, meta, frameEdit, frameFields, extra } = pendingLoad;
load(drawing, meta, frameEdit, frameFields, extra);
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
if (element.dataset.loading === "true") startLoadWatchdog();
}
} else if (message.type === CAD_LOADED_MESSAGE) {
stopLoadWatchdog();
element.dataset.loading = "false";
} else if (message.type === CAD_ERROR_MESSAGE) {
stopLoadWatchdog();
showFailure(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
} else if (message.type === CAD_CHANGED_MESSAGE) {
options.onChanged?.(message.dirty !== false);
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
options.onNavigate?.(message.direction);
} else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) {
options.onExport?.(message.fileFormat);
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
const resolve = resolveSave;
resolveSave = undefined;
resolve({ drawing: message.drawing, quantityTable: message.quantityTable ?? null });
}
};
window.addEventListener("message", onMessage);
return {
element,
beginLoading: () => {
element.dataset.loading = "true";
element.dataset.error = ""; // 앞선 실패 표시를 지운다
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
},
fail: (detail) => {
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
showFailure(detail);
},
load,
requestSave,
destroy: () => {
stopLoadWatchdog();
window.removeEventListener("message", onMessage);
},
};
}
@@ -1,21 +1,27 @@
/**
* B07 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
* 도각 편집 모드 — 캐드 화면에서 도각(양식)만 따로 열어 고치고 [완료]로 한 번에 반영한다.
* B07 에서 떼어 공용으로 둔다 — 도각을 읽고 쓰는 길(`api`)만 페이지가 넘긴다.
*
* 정본(`resources/template_2dDrawing/00_template_A1.json`)은 프로그램 기본 도각이라
* 건드리지 않는다. 고친 도각은 **회사 도각**(`storage/{회사}/templates/`)으로 저장되고,
* 이후 그리는 도면이 그것을 쓴다 (2026-09-01 사용자 확정).
* 시스템 양식(`resources/master_template/drawing/00_template_A1.json`)은 건드리지 않는다.
* 고친 도각은 **프로젝트 도각**(작업본 `{프로젝트}/templates/drawing/`)으로 저장되고,
* 이후 그리는 도면이 그것을 쓴다 (PLAN 10-5).
*
* 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 다시 그려진다.
*/
import { createButton, showToast } from "@ui/ui_template_elements";
import {
type CadDrawing,
fetchFrameTemplate,
importFrameTemplate,
resetFrameTemplate,
saveFrameTemplate,
} from "./B07_DesignDetail_Api_Fetch";
import type { CadHostDrawing } from "./cad_host";
/** 도각을 읽고 쓰는 길 — 페이지가 자기 서버 길로 채운다. */
export interface FrameTemplateApi<D extends CadHostDrawing> {
/** 편집할 도각 한 장 · 고친 도각인가 · 자리표에 보여 줄 실제 값. */
fetch: () => Promise<{ drawing: D; customized: boolean; fields?: Record<string, string> }>;
save: (drawing: D) => Promise<void>;
/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */
importFile: (file: File) => Promise<{ drawing: D; entity_count: number }>;
/** 고친 도각을 지우고 기본 도각으로 되돌린다. */
reset: () => Promise<void>;
}
export interface FrameTemplateEditor {
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
@@ -26,18 +32,18 @@ export interface FrameTemplateEditor {
isEditing: () => boolean;
}
interface Options {
projectId: string;
interface Options<D extends CadHostDrawing> {
api: FrameTemplateApi<D>;
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다).
* frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */
sendLoad: (
drawing: CadDrawing,
drawing: D,
meta: null,
frameEdit?: boolean,
frameFields?: Record<string, string>,
) => void;
/** CAD에서 현재 편집본을 받아온다. */
requestCadDrawing: () => Promise<CadDrawing>;
requestCadDrawing: () => Promise<D>;
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
restoreDrawing: () => void;
/** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */
@@ -46,16 +52,18 @@ interface Options {
currentDrawingInfo: () => { label: string; number: string } | null;
}
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
export function createFrameTemplateEditor<D extends CadHostDrawing>(
options: Options<D>,
): FrameTemplateEditor {
let editing = false;
// 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다.
let frameFields: Record<string, string> = {};
const banner = document.createElement("div");
banner.className = "b07-frame-edit";
banner.className = "cad-frame-edit";
banner.hidden = true;
const label = document.createElement("span");
label.className = "b07-frame-edit__label";
label.className = "cad-frame-edit__label";
banner.append(label);
const finishButton = createButton({
@@ -64,7 +72,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
onClick: () => void finish(),
});
/**
* 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다 (2026-09-01 신설).
* 프로젝트 도각을 처음 복사한 도각(`_initial/`)으로 되돌린다 (2026-09-01 신설).
* 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
*/
const resetButton = createButton({
@@ -75,7 +83,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
/**
* 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가
* 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 프로젝트 도각이 된다.
*/
const fileInput = document.createElement("input");
fileInput.type = "file";
@@ -95,7 +103,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
onClick: () => leave(),
});
const bannerButtons = document.createElement("div");
bannerButtons.className = "b07-frame-edit__buttons";
bannerButtons.className = "cad-frame-edit__buttons";
bannerButtons.append(finishButton, importButton, resetButton, cancelButton);
banner.append(bannerButtons, fileInput);
@@ -118,7 +126,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
if (!file) return;
importButton.disabled = true;
try {
const response = await importFrameTemplate(options.projectId, file);
const response = await options.api.importFile(file);
options.sendLoad(response.drawing, null, true, frameFields);
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
@@ -134,7 +142,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
async function enter(): Promise<void> {
try {
const response = await fetchFrameTemplate(options.projectId);
const response = await options.api.fetch();
// 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다.
const info = options.currentDrawingInfo();
frameFields = {
@@ -145,8 +153,8 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
button.disabled = true;
banner.hidden = false;
label.textContent = response.customized
? "도각 편집 중 — 회사 도각을 고치고 있습니다."
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
? "도각 편집 중 — 이 프로젝트에서 고친 도각입니다."
: "도각 편집 중 — 고치면 이 프로젝트 도각으로 저장됩니다.";
options.sendLoad(response.drawing, null, true, frameFields);
} catch (error) {
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
@@ -156,16 +164,16 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
async function resetToDefault(): Promise<void> {
if (
!window.confirm(
"회사 도각을 지우고 프로그램 기본 도각으로 되돌립니다.\n" +
"확정하지 않은 도면부터 기본 도각으로 나옵니다. 계속할까요?",
"이 프로젝트 도각을 처음 복사한 도각으로 되돌립니다.\n" +
"확정하지 않은 도면부터 되돌린 도각으로 나옵니다. 계속할까요?",
)
)
return;
resetButton.disabled = true;
try {
await resetFrameTemplate(options.projectId);
await options.api.reset();
options.onSaved();
showToast("기본 도각으로 되돌렸습니다.", "success");
showToast("처음 복사한 도각으로 되돌렸습니다.", "success");
leave();
} catch (error) {
showToast(
@@ -182,7 +190,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
finishButton.disabled = true;
try {
const drawing = await options.requestCadDrawing();
await saveFrameTemplate(options.projectId, drawing);
await options.api.save(drawing);
options.onSaved();
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
leave();