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:
@@ -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}],
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user