feat(B07): 도각 외부 파일 불러오기(DXF/DWG)와 자리표 배치 도구

- DXF -> 도각 JSON 변환 엔진 신설 (ezdxf) — 선·폴리선·원/호/타원/스플라인 평탄화·글자·점 변환, 블록·치수는 분해, 해치 등 미지원 요소는 제외
- DWG 는 ODA File Converter(.env ODA_CONVERTER_PATH) 경유 변환, 미설치 시 DXF 저장 안내로 폴백
- POST /{project_id}/frame-template/import 신설 — 파일을 편집 화면용 도면으로 반환(저장은 기존 [완료] 경로 유지), 20MB·2만 도형 상한
- 도각 편집 띠에 「파일 불러오기」 추가, 자리표 팔레트 신설 (글자 14종·그림 4종을 도면 중앙에 배치 후 이동)
- CAD 텍스트 선택 시 회색 점선 외곽선 표시 (출력물에는 미포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 12:41:22 +09:00
co-authored by Claude Opus 5
parent 39bb1e6754
commit 784d6df801
11 changed files with 635 additions and 75 deletions
+30 -27
View File
@@ -77,10 +77,7 @@ export interface CrossDesignInfo {
cross_slope_pct?: number;
paved?: boolean;
ditch: DitchSpec;
road_edges?: Record<
"left" | "right",
{ offset_m: number; elevation_m: number }
>;
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
design_elevation_m: number;
cut_area_m2: number;
fill_area_m2: number;
@@ -120,10 +117,7 @@ export interface DesignDrawingConfirmResponse {
design?: CrossDesignInfo | null;
}
async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
@@ -134,17 +128,14 @@ async function requestJson<T>(
signal: controller.signal,
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok)
throw new Error(payload.message ?? `HTTP ${response.status}`);
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
} finally {
window.clearTimeout(timeoutId);
}
}
export function fetchDesignDrawingList(
projectId: string,
): Promise<DesignDrawingListResponse> {
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
return requestJson(`/projects/${projectId}/design-drawings`);
}
@@ -152,9 +143,7 @@ export function fetchDesignDrawing(
projectId: string,
drawingId: string,
): Promise<DesignDrawingResponse> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
);
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
}
export function confirmDesignDrawing(
@@ -172,10 +161,7 @@ export function confirmDesignDrawing(
);
}
export function invalidateDesignDrawing(
projectId: string,
drawingId: string,
): Promise<void> {
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
{ method: "POST" },
@@ -191,22 +177,39 @@ export interface FrameTemplateResponse {
customized: boolean;
}
export function fetchFrameTemplate(
projectId: string,
): Promise<FrameTemplateResponse> {
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
return requestJson(`/projects/${projectId}/frame-template`);
}
export function saveFrameTemplate(
projectId: string,
drawing: CadDrawing,
): Promise<void> {
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
return requestJson(`/projects/${projectId}/frame-template`, {
method: "PUT",
body: JSON.stringify({ drawing }),
});
}
/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */
export async function importFrameTemplate(
projectId: string,
file: File,
): Promise<{ drawing: CadDrawing; entity_count: number }> {
const form = new FormData();
form.append("file", file);
// 파일 전송이라 requestJson(JSON 헤더·짧은 시한)을 쓰지 않는다.
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/frame-template/import`, {
method: "POST",
credentials: "include",
body: form,
});
const payload = (await response.json()) as {
drawing: CadDrawing;
entity_count: number;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */
export function resetFrameTemplate(projectId: string): Promise<void> {
return requestJson(`/projects/${projectId}/frame-template`, {
@@ -0,0 +1,230 @@
"""B07 외부 도각 파일 불러오기 — DXF(및 변환된 DWG)를 도각 JSON 으로 바꾼다.
고객이 내는 도각은 DWG 일 확률이 높지만 DWG 는 비공개 형식이라 파이썬이 바로 못 읽는다.
`.env` 의 `ODA_CONVERTER_PATH` 에 무료 변환기(ODA File Converter) 경로를 넣어 두면 DWG 를
DXF 로 바꿔 읽고, 없으면 「DXF 로 저장해 올려 달라」는 안내로 떨어진다 (2026-09-06 사용자 확정).
프로그램 값이 들어갈 자리는 여기서 알아맞히지 않는다 — 불러온 뒤 사용자가 자리표를
직접 놓는다. 좌표는 파일에 있는 그대로 쓴다(도각은 실치수 1:1 mm 로 그린다).
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from math import cos, radians, sin
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
import ezdxf
from ezdxf import colors as ezdxf_colors
logger = logging.getLogger(__name__)
_IMPORT_NS = uuid5(NAMESPACE_URL, "aislo/b07/frame-import")
_DEFAULT_COLOR = "#f5f7fa"
# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다.
_FLATTEN_MM = 0.2
_MAX_ENTITIES = 20000
def _entity_id(index: int) -> str:
return str(uuid5(_IMPORT_NS, str(index)))
def _color(entity: Any) -> str:
"""DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다."""
try:
aci = int(entity.dxf.color)
if aci in (0, 256): # BYBLOCK · BYLAYER
return _DEFAULT_COLOR
red, green, blue = ezdxf_colors.aci2rgb(aci)
return f"#{red:02x}{green:02x}{blue:02x}"
except Exception:
return _DEFAULT_COLOR
def _point(x: float, y: float) -> dict[str, float]:
return {"x": float(x), "y": float(y)}
def _base(index: int, entity: Any, kind: str) -> dict[str, Any]:
return {
"id": _entity_id(index),
"type": kind,
"lineColor": _color(entity),
"lineWidth": 1,
"layerId": str(getattr(entity.dxf, "layer", "0")),
}
def _line(index: int, entity: Any, start: Any, end: Any) -> dict[str, Any]:
return {
**_base(index, entity, "Line"),
"shapeData": {
"startPoint": _point(start[0], start[1]),
"endPoint": _point(end[0], end[1]),
},
}
def _polyline(index: int, entity: Any, points: list[Any], closed: bool) -> dict[str, Any] | None:
"""점 목록을 선분 묶음(PolyLine)으로 바꾼다. 점이 2개 미만이면 버린다."""
vertices = [(float(p[0]), float(p[1])) for p in points]
if closed and len(vertices) > 2:
vertices.append(vertices[0])
if len(vertices) < 2:
return None
children = [
{
**_base(index, entity, "Line"),
"id": str(uuid5(_IMPORT_NS, f"{index}:{seq}")),
"shapeData": {
"startPoint": _point(*vertices[seq]),
"endPoint": _point(*vertices[seq + 1]),
},
}
for seq in range(len(vertices) - 1)
]
return {**_base(index, entity, "PolyLine"), "shapeData": None, "children": children}
def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]:
rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0)
return {
**_base(index, entity, "Text"),
"shapeData": {
"label": label,
"basePoint": _point(insert[0], insert[1]),
"options": {
"textDirection": _point(cos(radians(rotation)), sin(radians(rotation))),
"textAlign": "left",
"textColor": _color(entity),
"fontSize": float(height) or 3.0,
"fontFamily": "sans-serif",
},
},
}
def _flatten(entity: Any) -> list[Any] | None:
"""원·호·타원·스플라인을 선분 점열로 편다. 못 펴면 None."""
try:
return list(entity.flattening(_FLATTEN_MM))
except Exception:
return None
def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]:
kind = entity.dxftype()
if kind == "LINE":
return [_line(index, entity, entity.dxf.start, entity.dxf.end)]
if kind == "LWPOLYLINE":
shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed))
return [shape] if shape else []
if kind == "POLYLINE":
points = [vertex.dxf.location for vertex in entity.vertices]
shape = _polyline(index, entity, points, bool(entity.is_closed))
return [shape] if shape else []
if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"):
points = _flatten(entity)
if not points:
return []
shape = _polyline(index, entity, points, kind in ("CIRCLE", "ELLIPSE"))
return [shape] if shape else []
if kind == "POINT":
location = entity.dxf.location
return [
{
**_base(index, entity, "Point"),
"shapeData": {"point": _point(location[0], location[1])},
}
]
if kind == "TEXT":
label = str(entity.dxf.text or "").strip()
if not label:
return []
return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))]
if kind == "MTEXT":
label = str(entity.plain_text() or "").strip()
if not label:
return []
height = float(entity.dxf.char_height or 3.0)
return [_text(index, entity, label, entity.dxf.insert, height)]
return []
def _expand(entity: Any) -> list[Any]:
"""블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다."""
if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"):
try:
return list(entity.virtual_entities())
except Exception:
logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype())
return []
return [entity]
def dxf_to_entities(path: Path) -> list[dict[str, Any]]:
"""DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다."""
document = ezdxf.readfile(str(path))
entities: list[dict[str, Any]] = []
index = 0
for source in document.modelspace():
for item in _expand(source):
entities.extend(_convert_entity(index, item))
index += 1
if len(entities) > _MAX_ENTITIES:
raise ValueError(
f"도형이 너무 많습니다({_MAX_ENTITIES}개 넘음)."
" 도각만 남겨 다시 저장해 주십시오."
)
if not entities:
raise ValueError("읽을 수 있는 도형이 없습니다. 선·글자가 있는 도각인지 확인해 주십시오.")
return entities
def _dwg_to_dxf(source: Path, work_dir: Path) -> Path:
"""ODA File Converter 로 DWG 를 DXF 로 바꾼다. 변환기가 없으면 안내와 함께 실패."""
converter = os.getenv("ODA_CONVERTER_PATH", "").strip()
if not converter or not Path(converter).is_file():
raise ValueError(
"DWG 는 그대로 읽지 못합니다. 캐드에서 DXF 로 저장해 올려 주십시오."
" (서버에 ODA File Converter 를 두면 DWG 도 바로 읽습니다)"
)
in_dir = work_dir / "in"
out_dir = work_dir / "out"
in_dir.mkdir(parents=True, exist_ok=True)
out_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(source, in_dir / source.name)
# ODA 인자: 입력폴더 출력폴더 출력버전 출력형식 재귀 감사
subprocess.run(
[converter, str(in_dir), str(out_dir), "ACAD2018", "DXF", "0", "1"],
check=True,
timeout=180,
capture_output=True,
)
converted = sorted(out_dir.glob("*.dxf"))
if not converted:
raise ValueError("DWG 를 DXF 로 바꾸지 못했습니다. 캐드에서 DXF 로 저장해 올려 주십시오.")
return converted[0]
def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]:
"""올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다."""
suffix = Path(filename).suffix.lower()
if suffix not in (".dxf", ".dwg"):
raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.")
with tempfile.TemporaryDirectory(prefix="aislo-frame-") as temporary:
work_dir = Path(temporary)
source = work_dir / f"frame{suffix}"
source.write_bytes(data)
target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source
try:
return dxf_to_entities(target)
except ezdxf.DXFError as exc:
raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc
@@ -20,13 +20,12 @@ from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
DRAWING_FORMAT,
FRAME_LAYER_ID,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing"
@@ -114,9 +113,14 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]:
1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다.
"""
return frame_document(template_entities(name))
def frame_document(entities: list[dict[str, Any]]) -> dict[str, Any]:
"""엔티티 목록을 도각 편집 화면이 그대로 싣는 도면 한 장으로 감싼다 (실치수 1:1)."""
return {
"format": DRAWING_FORMAT,
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in template_entities(name)],
"entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities],
"layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}],
}
+38 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path, PurePosixPath
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -26,13 +26,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
extract_quantity_table,
)
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,
use_title_fields,
validate_template_entities,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
@@ -59,6 +62,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingInvalidateResponse,
DesignDrawingListResponse,
DesignDrawingResponse,
FrameTemplateImportResponse,
FrameTemplateResponse,
FrameTemplateSaveRequest,
FrameTemplateSaveResponse,
@@ -585,6 +589,39 @@ async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONRe
)
# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다.
_FRAME_IMPORT_MAX_BYTES = 20 * 1024 * 1024
@router.post("/{project_id}/frame-template/import", response_model=FrameTemplateImportResponse)
async def import_frame_template(
project_id: UUID, file: UploadFile = File(...)
) -> FrameTemplateImportResponse | JSONResponse:
"""외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다.
아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
"""
try:
data = await file.read()
if len(data) > _FRAME_IMPORT_MAX_BYTES:
raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).")
entities = await asyncio.to_thread(import_frame_file, file.filename or "", data)
validate_template_entities(entities)
return FrameTemplateImportResponse(
project_id=str(project_id),
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("B07 도각 불러오기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "도각 파일을 읽지 못했습니다."},
)
@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
async def put_frame_template(
project_id: UUID, request: FrameTemplateSaveRequest
@@ -104,6 +104,15 @@ class FrameTemplateResponse(BaseModel):
customized: bool = False
class FrameTemplateImportResponse(BaseModel):
"""외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 바꾼 결과."""
status: str = "success"
project_id: str
drawing: dict[str, Any]
entity_count: int
class FrameTemplateSaveRequest(BaseModel):
"""도각 편집 화면이 [완료]에서 넘기는 도각."""
@@ -12,15 +12,19 @@ import { createButton, showToast } from "@ui/ui_template_elements";
import {
type CadDrawing,
fetchFrameTemplate,
importFrameTemplate,
resetFrameTemplate,
saveFrameTemplate,
} from "./B07_DesignDetail_Api_Fetch";
import { createPlaceholderPalette } from "./B07_DesignDetail_UI_FramePlaceholders";
export interface FrameTemplateEditor {
/** 도면 목록 아래에 놓는 「도각 편집」 버튼. */
button: HTMLButtonElement;
/** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */
banner: HTMLElement;
/** 자리표 목록 — 편집 중에만 보인다. */
tokens: HTMLElement;
/** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */
isEditing: () => boolean;
}
@@ -61,6 +65,23 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
variant: "ghost",
onClick: () => void resetToDefault(),
});
/**
* 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가
* 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직
* 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다.
*/
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".dxf,.dwg";
fileInput.hidden = true;
fileInput.addEventListener("change", () => void importFile());
const importButton = createButton({
label: "파일 불러오기",
variant: "ghost",
onClick: () => fileInput.click(),
});
const cancelButton = createButton({
label: "취소",
variant: "ghost",
@@ -68,8 +89,13 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
});
const bannerButtons = document.createElement("div");
bannerButtons.className = "b07-frame-edit__buttons";
bannerButtons.append(finishButton, resetButton, cancelButton);
banner.append(bannerButtons);
bannerButtons.append(finishButton, importButton, resetButton, cancelButton);
banner.append(bannerButtons, fileInput);
const palette = createPlaceholderPalette({
requestCadDrawing: () => options.requestCadDrawing(),
sendLoad: (drawing, meta) => options.sendLoad(drawing, meta),
});
const button = createButton({
label: "도각 편집",
@@ -80,16 +106,38 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
const leave = (): void => {
editing = false;
banner.hidden = true;
palette.setVisible(false);
button.disabled = false;
options.restoreDrawing();
};
async function importFile(): Promise<void> {
const file = fileInput.files?.[0];
fileInput.value = "";
if (!file) return;
importButton.disabled = true;
try {
const response = await importFrameTemplate(options.projectId, file);
options.sendLoad(response.drawing, null);
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
} catch (error) {
showToast(
error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.",
"error",
);
} finally {
importButton.disabled = false;
}
}
async function enter(): Promise<void> {
try {
const response = await fetchFrameTemplate(options.projectId);
editing = true;
button.disabled = true;
banner.hidden = false;
palette.setVisible(true);
label.textContent = response.customized
? "도각 편집 중 — 회사 도각을 고치고 있습니다."
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
@@ -139,5 +187,5 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
}
}
return { button, banner, isEditing: () => editing };
return { button, banner, tokens: palette.root, isEditing: () => editing };
}
@@ -0,0 +1,196 @@
/**
* B07 (2026-09-06 ).
*
* . `{{키}}`
* (`_fill_placeholders`) . .
*
* , ·
* . ( ).
*/
import { createButton, showToast } from "@ui/ui_template_elements";
import type { CadDrawing } from "./B07_DesignDetail_Api_Fetch";
/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */
const TEXT_TOKENS: readonly string[] = [
"도면명",
"도면번호",
"공사명",
"위치",
"시행청",
"용역회사",
"연도기번",
"사업량",
"과업책임자",
"분야별책임자",
"설계자",
"설계일자",
"축척_A1",
"축척_A3",
];
/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 그림째 빠진다. */
const IMAGE_TOKENS: readonly string[] = [
"회사로고",
"과업책임자서명",
"분야별책임자서명",
"설계자서명",
];
const TEXT_SIZE_MM = 5;
const IMAGE_WIDTH_MM = 32;
const IMAGE_HEIGHT_MM = 16;
/** 겹쳐 놓지 않도록 하나 놓을 때마다 이만큼 내려 찍는다. */
const STACK_STEP_MM = 8;
interface Options {
/** CAD에서 현재 편집본을 받아온다. */
requestCadDrawing: () => Promise<CadDrawing>;
/** CAD에 도면을 다시 싣는다. */
sendLoad: (drawing: CadDrawing, meta: null) => void;
}
interface Point {
x: number;
y: number;
}
/** 엔티티 목록의 한가운데 — 자리표를 처음 놓는 자리. 좌표가 없으면 원점. */
function centerOf(entities: Record<string, unknown>[]): Point {
const xs: number[] = [];
const ys: number[] = [];
const visit = (entity: Record<string, unknown>): void => {
const shape = (entity.shapeData ?? {}) as Record<string, unknown>;
for (const key of ["startPoint", "endPoint", "basePoint", "point"]) {
const value = shape[key] as Point | undefined;
if (value && typeof value.x === "number" && typeof value.y === "number") {
xs.push(value.x);
ys.push(value.y);
}
}
for (const vertex of (shape.points as Point[] | undefined) ?? []) {
if (vertex && typeof vertex.x === "number") {
xs.push(vertex.x);
ys.push(vertex.y);
}
}
for (const child of (entity.children as Record<string, unknown>[] | undefined) ?? []) {
visit(child);
}
};
for (const entity of entities) visit(entity);
if (xs.length === 0) return { x: 0, y: 0 };
const mid = (values: number[]): number => (Math.min(...values) + Math.max(...values)) / 2;
return { x: mid(xs), y: mid(ys) };
}
function layerIdOf(drawing: CadDrawing): string {
return drawing.layers[0]?.id ?? "0";
}
function textEntity(token: string, at: Point, layerId: string): Record<string, unknown> {
return {
id: crypto.randomUUID(),
type: "Text",
lineColor: "#f5f7fa",
lineWidth: 1,
layerId,
shapeData: {
label: `{{${token}}}`,
basePoint: { x: at.x, y: at.y },
options: {
textDirection: { x: 1, y: 0 },
textAlign: "center",
textColor: "#f5f7fa",
fontSize: TEXT_SIZE_MM,
fontFamily: "sans-serif",
},
},
};
}
function imageEntity(token: string, at: Point, layerId: string): Record<string, unknown> {
const halfWidth = IMAGE_WIDTH_MM / 2;
const halfHeight = IMAGE_HEIGHT_MM / 2;
return {
id: crypto.randomUUID(),
type: "Image",
lineColor: "#f5f7fa",
lineWidth: 1,
layerId,
shapeData: {
points: [
{ x: at.x - halfWidth, y: at.y - halfHeight },
{ x: at.x + halfWidth, y: at.y - halfHeight },
{ x: at.x + halfWidth, y: at.y + halfHeight },
{ x: at.x - halfWidth, y: at.y + halfHeight },
],
imageData: `{{${token}}}`,
},
};
}
export interface PlaceholderPalette {
/** 도각 편집 띠 아래에 붙는 자리표 목록 (평소엔 숨김). */
root: HTMLElement;
setVisible: (visible: boolean) => void;
}
export function createPlaceholderPalette(options: Options): PlaceholderPalette {
const root = document.createElement("div");
root.className = "b07-frame-tokens";
root.hidden = true;
const hint = document.createElement("span");
hint.className = "b07-frame-tokens__hint";
hint.textContent = "자리표 놓기 — 누르면 도면 가운데에 서고, 끌어서 자리를 잡음";
root.append(hint);
let placed = 0;
const place = async (token: string, kind: "text" | "image"): Promise<void> => {
try {
const drawing = await options.requestCadDrawing();
const center = centerOf(drawing.entities);
const at = { x: center.x, y: center.y - placed * STACK_STEP_MM };
const layerId = layerIdOf(drawing);
const entity =
kind === "text" ? textEntity(token, at, layerId) : imageEntity(token, at, layerId);
options.sendLoad({ ...drawing, entities: [...drawing.entities, entity] }, null);
placed += 1;
showToast(`${token}」 자리표를 놓았습니다. 끌어서 자리를 잡으십시오.`, "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "자리표를 놓지 못했습니다.", "error");
}
};
const group = (label: string, tokens: readonly string[], kind: "text" | "image"): void => {
const box = document.createElement("div");
box.className = "b07-frame-tokens__group";
const title = document.createElement("span");
title.className = "b07-frame-tokens__title";
title.textContent = label;
box.append(title);
for (const token of tokens) {
box.append(
createButton({
label: token,
variant: "ghost",
onClick: () => void place(token, kind),
}),
);
}
root.append(box);
};
group("글자", TEXT_TOKENS, "text");
group("그림", IMAGE_TOKENS, "image");
return {
root,
setVisible: (visible: boolean) => {
root.hidden = !visible;
if (!visible) placed = 0;
},
};
}
+1 -1
View File
@@ -481,7 +481,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const confirmButtonRow = document.createElement("div");
confirmButtonRow.className = "b07-drawing-actions__row";
confirmButtonRow.append(frameEditor.button, confirmButton);
confirmActions.append(frameEditor.banner, confirmButtonRow);
confirmActions.append(frameEditor.banner, frameEditor.tokens, confirmButtonRow);
drawingPanel.append(infoPanelHost, confirmActions);
+11 -33
View File
@@ -8,10 +8,7 @@
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type {
CrossDesignInfo,
DesignDrawingItem,
} from "./B07_DesignDetail_Api_Fetch";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -69,10 +66,7 @@ export function buildDrawingSidePanel(
return panel;
}
const drawingButton = (
drawing: DesignDrawingItem,
label: string,
): HTMLButtonElement => {
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button";
@@ -91,9 +85,7 @@ export function buildDrawingSidePanel(
? drawings.filter((item) => item.kind === group.kind)
: group.idPrefix
? drawings.filter(
(item) =>
item.id === group.idPrefix ||
item.id.startsWith(`${group.idPrefix}_`),
(item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`),
)
: drawings.filter((item) => item.id === group.blankId);
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
@@ -117,8 +109,7 @@ export function buildDrawingSidePanel(
const button = drawingButton(drawing, group.label);
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank")
button.title = "준비 중 — 도각만 표시합니다";
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
panel.append(button);
continue;
}
@@ -140,10 +131,7 @@ export function buildDrawingSidePanel(
return panel;
}
const GROUND_TYPE_LABEL: Record<
CrossDesignInfo["ground_type"],
keyof typeof ui_locales
> = {
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
soil: "B06_Design_Ground_Soil",
ripping_rock: "B06_Design_Ground_Ripping",
blasting_rock: "B06_Design_Ground_Blasting",
@@ -165,8 +153,7 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
function ditchLabel(design: CrossDesignInfo): string {
const ditch = design.ditch;
if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
return "없음";
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
if (ditch.type === "l_type")
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
@@ -202,23 +189,19 @@ export function buildDesignInfoPanel(
const heading = document.createElement("div");
heading.className = "b07-info__heading";
const stationName = document.createElement("strong");
const scopeLabel =
scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station");
stationName.textContent = `${scopeLabel} ${title}`;
const confirmed = design?.status === "confirmed";
const badge = document.createElement("span");
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
badge.textContent = confirmed
? L("B07_Info_Confirmed")
: L("B07_Info_Provisional");
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
heading.append(stationName, badge);
panel.append(heading);
if (!design) {
const empty = document.createElement("p");
empty.className = "b07-info__empty";
empty.textContent =
scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign");
panel.append(empty);
return panel;
}
@@ -233,9 +216,7 @@ export function buildDesignInfoPanel(
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow(
L("B07_Info_DitchSide"),
design.ditch_side === "left"
? L("B06_Design_Ditch_Left")
: L("B06_Design_Ditch_Right"),
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
),
);
@@ -245,10 +226,7 @@ export function buildDesignInfoPanel(
planTitle.textContent = L("B07_Info_Plan_Title");
plan.append(
planTitle,
infoRow(
L("B07_Info_DesignElevation"),
`${design.design_elevation_m.toFixed(2)}m`,
),
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
@@ -321,3 +321,43 @@
padding: 4px 8px;
font-size: 0.78rem;
}
/* 도각 자리표 목록 — 편집 중에만 보인다 (2026-09-06). 사이드바가 좁아 단추를 감싼다. */
.b07-frame-tokens {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
margin-top: 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-tokens[hidden] {
display: none;
}
.b07-frame-tokens__hint {
font-size: 0.72rem;
line-height: 1.4;
color: var(--color-text-secondary);
}
.b07-frame-tokens__group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.b07-frame-tokens__title {
width: 100%;
font-size: 0.72rem;
color: var(--color-text-secondary);
}
.b07-frame-tokens__group > button {
padding: 2px 6px;
font-size: 0.72rem;
}
@@ -8,6 +8,9 @@ import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../stat
import { type Entity, EntityName, type JsonEntity } from './Entity';
import type { LineEntity } from './LineEntity.ts';
/** 글자를 집었을 때 두르는 외곽선 색 — 도면 선과 헷갈리지 않게 회색. */
const TEXT_SELECTION_OUTLINE_COLOR = '#9aa0a6';
export interface TextOptions {
textDirection: Vector;
textAlign: 'left' | 'center' | 'right';
@@ -49,14 +52,26 @@ export class TextEntity implements Entity {
parentHighlighted?: boolean,
parentSelected?: boolean
): void {
drawController.setLineStyles(
parentHighlighted ?? isEntityHighlighted(this),
parentSelected ?? isEntitySelected(this),
this.lineColor,
this.lineWidth,
this.lineDash
);
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
const selected = parentSelected ?? isEntitySelected(this);
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
drawController.drawText(this.label, this.basePoint, this.options);
// 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이
// 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가
// 없어 이 선이 실리지 않는다.
if (highlighted || selected) {
const box = this.getBoundingBox();
const corners = [
new Point(box.xmin, box.ymin),
new Point(box.xmax, box.ymin),
new Point(box.xmax, box.ymax),
new Point(box.xmin, box.ymax),
];
drawController.setLineStyles(false, false, TEXT_SELECTION_OUTLINE_COLOR, 1, [4, 4]);
for (let index = 0; index < corners.length; index++) {
drawController.drawLine(corners[index], corners[(index + 1) % corners.length]);
}
}
}
public move(x: number, y: number) {