fix(B07): 편집이 사라지지 않게 하고 확정한 도면을 잠근다
사용자 편의성 점검 15건 중 반영 확정분 12건. 편집 손실 방지 - CAD에 미저장 표시(drawingDirty)를 두고, 도면을 바꾸기 전에 묻는다. 전에는 그은 선이 경고 없이 사라졌다(실측: 캔버스 서명이 원본과 동일). - 도면 전환을 겹쳐 눌러도 늦게 온 응답이 화면을 덮지 않게 가드를 뒀다. - 도면층 삭제·객체 이동을 되돌리기에 실어 Ctrl+Z로 돌아오게 했다. 확정한 도면은 읽기 전용 - runCommand 한 곳에서 보는 명령만 통과시킨다(허용 명시 방식). - 도면을 실을 때 앞 도면의 그리기 도구를 내린다 — 켜 둔 도구가 확정본에도 계속 그렸다. - 확정 버튼 자리를 [현재 도면 확정] / [수정]으로 가른다. 확정 해제는 [수정] 한 곳뿐 — 되돌리기·색 고르기로 확정이 풀리던 경로를 없앴다. 그 밖 - 잠금 도면층(도각·등고선·계류)은 마우스가 스쳐도 하이라이트하지 않는다. - 준비 중 도면 7종도 빈 도각으로 열린다(눌리지 않는 회색 버튼 제거). - 도면층 행이 잘리지 않게 패널을 300px로 넓히고 이름을 줄여 담는다. - 기본 그리기 색을 검정으로 — 흰 종이에 흰 선이라 안 보였다. - 색 고르는 동안 변경 통지가 연발하지 않는다. - 수량표는 앞 단계 산출물이라 도면에서 고치지 못하게 막고 안내한다. - 자동백업 칸을 도면별로 나눈다 — 되살리면 다른 도면에 붙었다. - 횡단 장 id를 시작 측점 기준으로 바꿔 구간이 달라져도 옛 확정이 안 붙는다. - 회사 도각 저장 전에 좌표를 검사하고, 기본 도각으로 되돌리기를 연다. - 횡단 파일이 없어도 나머지 도면 목록은 남는다. - 불러오는 중·실패를 화면에 표시한다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,9 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
|
||||
export interface DesignDrawingItem {
|
||||
id: string;
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed";
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
confirmed: boolean;
|
||||
@@ -81,7 +83,9 @@ export interface DesignDrawingResponse {
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
id: string;
|
||||
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed";
|
||||
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
|
||||
kind:
|
||||
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
confirmed: boolean;
|
||||
@@ -184,3 +188,10 @@ export function saveFrameTemplate(
|
||||
body: JSON.stringify({ drawing }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */
|
||||
export function resetFrameTemplate(projectId: string): Promise<void> {
|
||||
return requestJson(`/projects/${projectId}/frame-template`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,11 +25,15 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_layer,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
_A1_INNER,
|
||||
_fill_placeholders,
|
||||
_load_template,
|
||||
frame_entities,
|
||||
)
|
||||
|
||||
COVER_TEMPLATE = "00_template_cover"
|
||||
# 빈 도면(준비 중)이 그림을 얹을 자유 도면층 — 도각만으로는 그릴 자리가 없다.
|
||||
BLANK_LAYER_ID = "b08-blank-draft"
|
||||
# 표지는 엔티티가 전부 도각(잠금)이라 그대로 두면 덧그릴 자리가 없다 — 잠금 아닌
|
||||
# 도면층을 하나 실어 사용자가 적을 자리를 준다(2026-09-01).
|
||||
NOTE_LAYER_ID = "b08-cover-note"
|
||||
@@ -60,3 +64,20 @@ def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) -
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_blank_drawing(drawing_id: str, label: str) -> dict[str, Any]:
|
||||
"""아직 내용을 만들지 않은 도면 — **도각만 실어** 연다 (2026-09-01 사용자 지시).
|
||||
|
||||
목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다. 도각이라도 열어
|
||||
두면 사용자가 직접 그려 넣을 수도 있다. 도면명은 도각 표제란에 채운다.
|
||||
"""
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
# fit=False — 담을 콘텐츠가 없으니 A1 실치수 그대로 둔다.
|
||||
"entities": frame_entities(drawing_id, _A1_INNER, fit=False, fields={"도면명": label}),
|
||||
"layers": [
|
||||
_layer(BLANK_LAYER_ID, "작도"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
usable_area,
|
||||
)
|
||||
|
||||
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않는 형식.
|
||||
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})")
|
||||
# 장 도면 id — 측점 도면(cross_00020m)과 겹치지 않게 `s`를 끼운다(cross_s00020m).
|
||||
# 옛 순번 형식(cross_s01)도 읽어 준다 — 이미 확정한 매니페스트가 그 이름을 갖고 있다.
|
||||
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?")
|
||||
|
||||
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
|
||||
_BLOCK_GAP_MM = 8.0
|
||||
@@ -147,11 +148,15 @@ def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str,
|
||||
count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]]
|
||||
group = blocks[start : start + count]
|
||||
number = len(sheets) + 1
|
||||
chainages = [chainage for chainage, _w, _h in group]
|
||||
sheets.append(
|
||||
{
|
||||
"id": f"cross_s{number:02d}",
|
||||
# id는 **시작 측점**으로 짓는다. 순번(`cross_s01`)으로 지으면 B06 설계가
|
||||
# 바뀌어 한 장에 담기는 측점 수가 달라졌을 때 같은 이름이 다른 구간을
|
||||
# 가리키고, 옛 확정 표시가 그대로 새 구간에 붙는다(2026-09-01 지적).
|
||||
"id": f"cross_s{chainages[0]:05d}m",
|
||||
"number": number,
|
||||
"chainages": [chainage for chainage, _w, _h in group],
|
||||
"chainages": chainages,
|
||||
"rows": len(row_heights),
|
||||
"slots": _slots(col_widths, row_heights, count),
|
||||
}
|
||||
|
||||
@@ -94,10 +94,52 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def validate_template_entities(entities: list[dict[str, Any]]) -> None:
|
||||
"""도면 조립이 실제로 쓰는 좌표가 숫자인지 본다. 어긋나면 ValueError.
|
||||
|
||||
검사 없이 받으면 좌표가 빠진 도형 하나로 **이후 모든 도면 요청이 500**이 되고
|
||||
되돌릴 길이 없다(2026-09-01 지적). 여기서 막으면 편집 화면에 400으로 돌아간다.
|
||||
`_transform_entity`·`entities_bbox`가 읽는 키만 본다 — 그 밖은 그대로 통과시킨다.
|
||||
"""
|
||||
|
||||
def _check(entity: Any, where: str) -> None:
|
||||
if not isinstance(entity, dict):
|
||||
raise ValueError(f"도각 도형이 올바르지 않습니다 ({where}).")
|
||||
shape = entity.get("shapeData")
|
||||
if isinstance(shape, dict):
|
||||
for key in ("startPoint", "endPoint", "basePoint", "point", "center"):
|
||||
point = shape.get(key)
|
||||
if point is None:
|
||||
continue
|
||||
if not isinstance(point, dict) or not all(
|
||||
isinstance(point.get(axis), (int, float)) for axis in ("x", "y")
|
||||
):
|
||||
raise ValueError(f"도각 도형의 {key} 좌표가 숫자가 아닙니다 ({where}).")
|
||||
if "radius" in shape and not isinstance(shape["radius"], (int, float)):
|
||||
raise ValueError(f"도각 도형의 반지름이 숫자가 아닙니다 ({where}).")
|
||||
children = entity.get("children")
|
||||
if isinstance(children, list):
|
||||
for index, child in enumerate(children):
|
||||
_check(child, f"{where}:{index}")
|
||||
|
||||
for index, entity in enumerate(entities):
|
||||
_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():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def save_company_template(
|
||||
company_dir: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE
|
||||
) -> Path:
|
||||
"""편집한 도각을 회사 도각 파일로 저장한다. 프로그램 기본 도각은 건드리지 않는다."""
|
||||
validate_template_entities(entities)
|
||||
path = company_template_path(company_dir, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
document = {
|
||||
|
||||
@@ -25,6 +25,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
extract_quantity_table,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
clear_company_template,
|
||||
company_template_path,
|
||||
frame_template_document,
|
||||
save_company_template,
|
||||
@@ -426,3 +427,24 @@ async def put_frame_template(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "도각을 저장하지 못했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse)
|
||||
async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse:
|
||||
"""회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설).
|
||||
|
||||
되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다.
|
||||
"""
|
||||
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)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, 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": "기본 도각으로 되돌리지 못했습니다."},
|
||||
)
|
||||
|
||||
@@ -22,7 +22,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing, map_area_mm
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import (
|
||||
build_blank_drawing,
|
||||
build_cover_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
@@ -55,6 +58,19 @@ MASS_HAUL_ID = "mass_haul"
|
||||
WATERSHED_ID = "watershed"
|
||||
COVER_ID = "cover"
|
||||
|
||||
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
|
||||
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
|
||||
("blank_plan_terrain", "계획평면도(지형)"),
|
||||
("blank_plan_route", "계획평면도(노선배치도)"),
|
||||
("blank_plan_layout", "계획평면도(배치도)"),
|
||||
("blank_plan_lidar", "계획평면도(라이다)"),
|
||||
("blank_cross_standard", "표준 횡단면도"),
|
||||
("blank_standard", "표준도"),
|
||||
("blank_landuse", "용지도"),
|
||||
)
|
||||
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
@@ -285,7 +301,14 @@ def _drawing_list(
|
||||
)
|
||||
for chunk in longitudinal_chunks(longitudinal)
|
||||
]
|
||||
for sheet in _cross_sheet_plan(project_root, longitudinal_path, designs):
|
||||
# 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/`
|
||||
# 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적).
|
||||
try:
|
||||
sheets = _cross_sheet_plan(project_root, longitudinal_path, designs)
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc)
|
||||
sheets = []
|
||||
for sheet in sheets:
|
||||
chainages = sheet["chainages"]
|
||||
first = station_by_chainage.get(chainages[0], {})
|
||||
span = f"{chainages[0]}m"
|
||||
@@ -314,6 +337,10 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 아직 내용을 만들지 않은 도면도 **빈 도각으로 열린다**(2026-09-01 사용자 지시).
|
||||
# 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보인다.
|
||||
for drawing_id, label in BLANK_DRAWINGS:
|
||||
drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label))
|
||||
return drawings
|
||||
|
||||
|
||||
@@ -501,6 +528,11 @@ def _read_drawing(
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
if drawing_id in BLANK_LABELS:
|
||||
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다.
|
||||
label = BLANK_LABELS[drawing_id]
|
||||
return "blank", label, build_blank_drawing(drawing_id, label), False, None
|
||||
|
||||
if drawing_id == COVER_ID:
|
||||
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.
|
||||
return "cover", "표지", build_cover_drawing(drawing_id), False, None
|
||||
|
||||
@@ -9,7 +9,8 @@ class DesignDrawingItem(BaseModel):
|
||||
"""B06 확정 산출물에서 노출하는 도면 메타데이터."""
|
||||
|
||||
id: str
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed"]
|
||||
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"]
|
||||
label: str
|
||||
chainage_m: float | None = None
|
||||
confirmed: bool = False
|
||||
@@ -31,7 +32,8 @@ class DesignDrawingResponse(BaseModel):
|
||||
project_id: str
|
||||
route_id: int
|
||||
id: str
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed"]
|
||||
# blank: 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
|
||||
kind: Literal["cover", "longitudinal", "cross", "mass_haul", "watershed", "blank"]
|
||||
label: str
|
||||
drawing: dict[str, Any]
|
||||
confirmed: bool = False
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
type CadDrawing,
|
||||
fetchFrameTemplate,
|
||||
resetFrameTemplate,
|
||||
saveFrameTemplate,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
|
||||
@@ -53,12 +54,21 @@ export function createFrameTemplateEditor(
|
||||
variant: "filled",
|
||||
onClick: () => void finish(),
|
||||
});
|
||||
/**
|
||||
* 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다 (2026-09-01 신설).
|
||||
* 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
|
||||
*/
|
||||
const resetButton = createButton({
|
||||
label: "기본 도각으로",
|
||||
variant: "ghost",
|
||||
onClick: () => void resetToDefault(),
|
||||
});
|
||||
const cancelButton = createButton({
|
||||
label: "취소",
|
||||
variant: "ghost",
|
||||
onClick: () => leave(),
|
||||
});
|
||||
banner.append(finishButton, cancelButton);
|
||||
banner.append(finishButton, resetButton, cancelButton);
|
||||
|
||||
const button = createButton({
|
||||
label: "도각 편집",
|
||||
@@ -91,6 +101,32 @@ export function createFrameTemplateEditor(
|
||||
}
|
||||
}
|
||||
|
||||
async function resetToDefault(): Promise<void> {
|
||||
if (
|
||||
!window.confirm(
|
||||
"회사 도각을 지우고 프로그램 기본 도각으로 되돌립니다.\n" +
|
||||
"확정하지 않은 도면부터 기본 도각으로 나옵니다. 계속할까요?",
|
||||
)
|
||||
)
|
||||
return;
|
||||
resetButton.disabled = true;
|
||||
try {
|
||||
await resetFrameTemplate(options.projectId);
|
||||
options.onSaved();
|
||||
showToast("기본 도각으로 되돌렸습니다.", "success");
|
||||
leave();
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "기본 도각으로 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
resetButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finish(): Promise<void> {
|
||||
if (!editing) return;
|
||||
finishButton.disabled = true;
|
||||
|
||||
@@ -44,9 +44,12 @@ import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
kind: "cover" | "cross" | "longitudinal" | "mass_haul" | "watershed";
|
||||
kind: DesignDrawingItem["kind"];
|
||||
/** 도면 식별자 — CAD가 자동백업 칸을 도면별로 나누는 데 쓴다. */
|
||||
drawingId: string;
|
||||
title: string;
|
||||
info: string;
|
||||
/** 확정본은 CAD에서 읽기 전용이 된다. 푸는 길은 [수정] 버튼 하나뿐. */
|
||||
confirmed: boolean;
|
||||
quantityTable: QuantityTable | null;
|
||||
hasPrev: boolean;
|
||||
@@ -80,23 +83,29 @@ const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate";
|
||||
const CAD_TOAST_MESSAGE = "aislo:b08:toast";
|
||||
const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
|
||||
/** 도면 구성 12분류 (2026-08-29 사용자 확정 순서). kind가 없으면 아직 만들지 않는 도면. */
|
||||
/**
|
||||
* 도면 구성 12분류 (2026-08-29 사용자 확정 순서).
|
||||
*
|
||||
* 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다
|
||||
* (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다.
|
||||
*/
|
||||
const DRAWING_GROUPS: readonly {
|
||||
label: string;
|
||||
kind?: DesignDrawingItem["kind"];
|
||||
blankId?: string;
|
||||
}[] = [
|
||||
{ label: "표지", kind: "cover" },
|
||||
{ label: "계획평면도(지형)" },
|
||||
{ label: "계획평면도(노선배치도)" },
|
||||
{ label: "계획평면도(배치도)" },
|
||||
{ label: "계획평면도(라이다)" },
|
||||
{ label: "계획평면도(지형)", blankId: "blank_plan_terrain" },
|
||||
{ label: "계획평면도(노선배치도)", blankId: "blank_plan_route" },
|
||||
{ label: "계획평면도(배치도)", blankId: "blank_plan_layout" },
|
||||
{ label: "계획평면도(라이다)", blankId: "blank_plan_lidar" },
|
||||
{ label: "종단면도", kind: "longitudinal" },
|
||||
{ label: "표준 횡단면도" },
|
||||
{ label: "표준 횡단면도", blankId: "blank_cross_standard" },
|
||||
{ label: "횡단면도", kind: "cross" },
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
{ label: "유역도(배수 유역도)", kind: "watershed" },
|
||||
{ label: "표준도" },
|
||||
{ label: "용지도" },
|
||||
{ label: "표준도", blankId: "blank_standard" },
|
||||
{ label: "용지도", blankId: "blank_landuse" },
|
||||
];
|
||||
|
||||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||||
@@ -144,24 +153,30 @@ function buildDrawingSidePanel(
|
||||
for (const group of DRAWING_GROUPS) {
|
||||
const items = group.kind
|
||||
? drawings.filter((item) => item.kind === group.kind)
|
||||
: [];
|
||||
// 한 장짜리(와 아직 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||||
: drawings.filter((item) => item.id === group.blankId);
|
||||
// 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다.
|
||||
if (items.length <= 1) {
|
||||
const [drawing] = items;
|
||||
const button = drawing
|
||||
? drawingButton(drawing, group.label)
|
||||
: document.createElement("button");
|
||||
// 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다.
|
||||
if (!drawing) {
|
||||
button.type = "button";
|
||||
button.className = "b07-drawing-button";
|
||||
button.disabled = true;
|
||||
button.title = "준비 중";
|
||||
const placeholder = document.createElement("button");
|
||||
placeholder.type = "button";
|
||||
placeholder.className = "b07-drawing-button";
|
||||
placeholder.disabled = true;
|
||||
placeholder.title = "준비 중";
|
||||
placeholder.dataset.pending = "true";
|
||||
const name = document.createElement("span");
|
||||
name.className = "b07-drawing-button__name";
|
||||
name.textContent = group.label;
|
||||
button.append(name);
|
||||
placeholder.append(name);
|
||||
panel.append(placeholder);
|
||||
continue;
|
||||
}
|
||||
button.dataset.pending = String(!drawing);
|
||||
const button = drawingButton(drawing, group.label);
|
||||
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
|
||||
button.dataset.pending = String(drawing.kind === "blank");
|
||||
if (drawing.kind === "blank")
|
||||
button.title = "준비 중 — 도각만 표시합니다";
|
||||
panel.append(button);
|
||||
continue;
|
||||
}
|
||||
@@ -356,13 +371,35 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CAD에 저장하지 않은 편집이 있는가. CAD가 편집마다 알려 주고, 도면을 싣거나
|
||||
* 저장하면 내려간다. 이 값이 참인 채로 도면을 바꾸면 그은 선이 소리 없이 사라진다
|
||||
* (2026-09-01 실측) — 그래서 바꾸기 전에 묻는다.
|
||||
*/
|
||||
let cadDirty = false;
|
||||
|
||||
/**
|
||||
* 확정·미확정에 따라 버튼 한 자리를 바꾼다 (2026-09-01 사용자 확정).
|
||||
* 미확정이면 [현재 도면 확정], 확정이면 [수정] — 확정을 푸는 유일한 길이다.
|
||||
*/
|
||||
const confirmButton = createButton({
|
||||
label: "현재 도면 확정",
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentDrawing(),
|
||||
onClick: () => {
|
||||
if (currentConfirmed) void reopenCurrentDrawing();
|
||||
else void confirmCurrentDrawing();
|
||||
},
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
|
||||
const applyConfirmButtonState = (): void => {
|
||||
confirmButton.textContent = currentConfirmed ? "수정" : "현재 도면 확정";
|
||||
confirmButton.title = currentConfirmed
|
||||
? "확정을 풀고 이 도면을 고칩니다."
|
||||
: "이 도면을 확정하고 저장합니다.";
|
||||
confirmButton.disabled = !currentDrawing || currentDrawing.kind === "blank";
|
||||
};
|
||||
|
||||
const findButton = (drawingId: string) =>
|
||||
drawingListEl?.querySelector<HTMLButtonElement>(
|
||||
`.b07-drawing-button[data-drawing-id="${drawingId}"]`,
|
||||
@@ -382,6 +419,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
index: number,
|
||||
): DesignMeta => ({
|
||||
kind: drawing.kind,
|
||||
drawingId: drawing.id,
|
||||
title: drawing.label,
|
||||
info: drawing.kind === "cross" ? drawing.label : "",
|
||||
confirmed: response.confirmed,
|
||||
@@ -451,18 +489,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
/** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */
|
||||
const mayDiscardEdits = (): boolean => {
|
||||
if (!cadDirty) return true;
|
||||
return window.confirm(
|
||||
"저장하지 않은 편집이 있습니다.\n" +
|
||||
"지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" +
|
||||
"남기려면 [취소]를 누르고 [현재 도면 확정]으로 저장하세요.",
|
||||
);
|
||||
};
|
||||
|
||||
/** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */
|
||||
let loadInFlight = false;
|
||||
|
||||
const loadDrawing = async (drawing: DesignDrawingItem, index: number) => {
|
||||
if (!projectId) return;
|
||||
if (!projectId || loadInFlight) return;
|
||||
loadInFlight = true;
|
||||
highlightActive(drawing.id);
|
||||
const button = findButton(drawing.id);
|
||||
if (button) button.dataset.loading = "true";
|
||||
cadHost.dataset.loading = "true";
|
||||
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
|
||||
try {
|
||||
const response = await requestDrawing(drawing);
|
||||
currentDrawing = drawing;
|
||||
currentIndex = index;
|
||||
currentConfirmed = response.confirmed;
|
||||
confirmButton.disabled = response.confirmed;
|
||||
cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다
|
||||
applyConfirmButtonState();
|
||||
updateInfoPanel(drawing, response);
|
||||
sendLoad(response.drawing, buildMeta(drawing, response, index));
|
||||
} catch (error) {
|
||||
@@ -471,12 +525,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "CAD 도면을 불러오지 못했습니다.";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
if (currentDrawing) highlightActive(currentDrawing.id);
|
||||
} finally {
|
||||
loadInFlight = false;
|
||||
if (button) button.dataset.loading = "false";
|
||||
}
|
||||
};
|
||||
|
||||
const selectDrawing = (drawing: DesignDrawingItem) => {
|
||||
if (drawing === currentDrawing || !mayDiscardEdits()) return;
|
||||
void loadDrawing(drawing, drawings.indexOf(drawing));
|
||||
};
|
||||
|
||||
@@ -484,6 +542,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (currentIndex < 0) return;
|
||||
const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (target < 0 || target >= drawings.length) return;
|
||||
if (!mayDiscardEdits()) return;
|
||||
void loadDrawing(drawings[target], target);
|
||||
};
|
||||
|
||||
@@ -514,11 +573,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
currentConfirmed = true;
|
||||
currentDrawing.confirmed = true;
|
||||
confirmButton.disabled = true;
|
||||
cadDirty = false; // 저장했다
|
||||
applyConfirmButtonState();
|
||||
drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다.
|
||||
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "true";
|
||||
// 저장본을 다시 실어 CAD를 읽기 전용으로 돌린다 — 확정한 도면은 고칠 수 없다.
|
||||
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
|
||||
// 편집이 열린 어긋난 순간이 생긴다.
|
||||
await loadDrawing(currentDrawing, currentIndex);
|
||||
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
|
||||
if (currentDrawing.kind === "cross") {
|
||||
infoPanelHost.replaceChildren(
|
||||
@@ -542,29 +606,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidateCurrentDrawing = async () => {
|
||||
if (!projectId || !currentDrawing) return;
|
||||
const wasConfirmed = currentConfirmed;
|
||||
drawingCache.delete(currentDrawing.id);
|
||||
currentConfirmed = false;
|
||||
allDrawingsConfirmed = false;
|
||||
currentDrawing.confirmed = false;
|
||||
confirmButton.disabled = false;
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "false";
|
||||
if (wasConfirmed) {
|
||||
try {
|
||||
await invalidateDesignDrawing(projectId, currentDrawing.id);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
/**
|
||||
* [수정] — 확정을 풀고 다시 고칠 수 있게 한다. **확정이 풀리는 유일한 길**이다
|
||||
* (2026-09-01 사용자 확정). 예전에는 CAD의 편집 통지가 확정을 풀어서, 되돌리기나
|
||||
* 색 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
*/
|
||||
async function reopenCurrentDrawing(): Promise<void> {
|
||||
if (!projectId || !currentDrawing || !currentConfirmed) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await invalidateDesignDrawing(projectId, currentDrawing.id);
|
||||
drawingCache.delete(currentDrawing.id);
|
||||
currentConfirmed = false;
|
||||
allDrawingsConfirmed = false;
|
||||
currentDrawing.confirmed = false;
|
||||
const button = findButton(currentDrawing.id);
|
||||
if (button) button.dataset.confirmed = "false";
|
||||
applyConfirmButtonState();
|
||||
// 확정을 풀면 서버가 원본에서 다시 그린다 — 그 도면을 실어야 편집이 열린다.
|
||||
await loadDrawing(currentDrawing, currentIndex);
|
||||
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도면 확정 상태를 되돌리지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const frameEditor = createFrameTemplateEditor({
|
||||
projectId: projectId as string,
|
||||
@@ -589,6 +661,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
drawing?: CadDrawing;
|
||||
quantityTable?: QuantityTable | null;
|
||||
direction?: "prev" | "next";
|
||||
dirty?: boolean;
|
||||
kind?: string;
|
||||
text?: string;
|
||||
actionId?: string;
|
||||
@@ -622,9 +695,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
cadHost.dataset.error =
|
||||
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
|
||||
cadHost.dataset.loading = "false";
|
||||
showToast(cadHost.dataset.error, "error");
|
||||
} else if (message.type === CAD_CHANGED_MESSAGE) {
|
||||
// 도각을 고치는 중에 온 변경 알림은 도면 편집이 아니다 — 확정을 풀면 안 된다.
|
||||
if (!frameEditor.isEditing()) void invalidateCurrentDrawing();
|
||||
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
|
||||
// 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다.
|
||||
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
|
||||
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
|
||||
navigateDrawing(message.direction);
|
||||
} else if (
|
||||
|
||||
@@ -63,8 +63,13 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 아직 만들지 않는 도면 — 자리만 잡아 둔 버튼. */
|
||||
/* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되
|
||||
못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */
|
||||
.b07-drawing-button[data-pending="true"] {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.b07-drawing-button[data-pending="true"]:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -144,6 +149,40 @@
|
||||
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;
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
/* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */
|
||||
--cad-command-height: 86px;
|
||||
--cad-status-height: 28px;
|
||||
--cad-panel-width: 248px;
|
||||
/* 도면층 한 행에 아이콘 6개와 이름이 함께 들어가는 폭. 248px에서는 이름 몫이
|
||||
21px밖에 남지 않아 글자가 뭉개졌다(2026-09-01 실측). */
|
||||
--cad-panel-width: 300px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text-body);
|
||||
background: var(--color-bg);
|
||||
@@ -296,7 +298,8 @@ body > canvas[data-id="canvas"] {
|
||||
width: 44px;
|
||||
padding: 1px;
|
||||
background: var(--cad-chrome-sunken);
|
||||
border: 1px solid var(--cad-line);
|
||||
/* 흰색을 골라도 스와치 자리가 보이게 테두리를 진하게 준다 (2026-09-01) */
|
||||
border: 1px solid var(--cad-text-muted);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -442,12 +445,29 @@ body > canvas[data-id="canvas"] {
|
||||
max-height: calc(100vh - 250px);
|
||||
padding: 9px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.cad-layer-manager button {
|
||||
min-height: 36px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
/* 도면층 행: 이름은 줄여 담고 아이콘은 줄이지 않는다. 이름이 안 줄면 오른쪽 아이콘이
|
||||
행 밖으로 밀려 잘린다 (2026-09-01 실측: 내용 237~286px / 보이는 폭 229px). */
|
||||
.cad-layer-manager .layer > button > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cad-layer-manager .layer > button > div {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 행 안의 아이콘 버튼은 여백을 줄여 이름 몫을 남긴다 — 아이콘 6개가 한 행을 다 먹었다. */
|
||||
.cad-layer-manager .layer > button [data-size="small"] {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */
|
||||
.cad-layer-manager button[data-type="regular"] {
|
||||
background: var(--cad-chrome-sunken);
|
||||
|
||||
@@ -40,11 +40,14 @@ export enum HtmlEvent {
|
||||
|
||||
/** 부모(B08 페이지)가 도면과 함께 넘기는 설계 컨텍스트 (수량 패널 표시용). */
|
||||
export interface DesignMeta {
|
||||
kind: 'cross' | 'longitudinal' | 'mass_haul' | 'watershed';
|
||||
kind: 'cover' | 'cross' | 'longitudinal' | 'mass_haul' | 'watershed' | 'blank';
|
||||
/** 도면 식별자 — 자동백업을 도면별로 나누는 열쇠. */
|
||||
drawingId: string;
|
||||
/** 패널 제목 (측점 라벨, 예: "2+0.0" 또는 "종단도 전체") */
|
||||
title: string;
|
||||
/** 측점 부가 정보 (예: "STA.0+050.000") */
|
||||
info: string;
|
||||
/** 확정본은 읽기 전용 — 그리기·수정·값 편집이 막힌다. 부모의 [수정]으로 푼다. */
|
||||
confirmed: boolean;
|
||||
/** 수량 산출표 값 (횡단도만). 미산정 항목은 null. */
|
||||
quantityTable: Record<string, number | null> | null;
|
||||
|
||||
@@ -56,6 +56,45 @@ for (const command of ALL_COMMANDS) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정한 도면에서도 쓸 수 있는 명령 — **보는 일만 하는 것들**이다
|
||||
* (2026-09-01 사용자 확정: 확정하면 수정이 안 되고, 풀려면 [수정]을 누른다).
|
||||
*
|
||||
* 허용을 명시하는 쪽으로 짠다. 새 편집 명령이 늘어도 자동으로 막히고, 반대로 짜면
|
||||
* 새 명령마다 여기 적어야 해 언젠가 빠뜨린다.
|
||||
*/
|
||||
const VIEW_ONLY_COMMAND_IDS: ReadonlySet<string> = new Set([
|
||||
// 보기 — 확대·이동·다시그리기·선택
|
||||
...VIEW_COMMANDS.map((command) => command.id),
|
||||
// 조회 — 거리·면적·각도처럼 재기만 하는 것
|
||||
...INQUIRY_COMMANDS.map((command) => command.id),
|
||||
// 도면층 보이기·잠금 — 객체를 건드리지 않는 것만 (LAYMCH·LAYCUR·LAYMRG·LAYDEL 제외)
|
||||
'LAYER',
|
||||
'LAYCURSET',
|
||||
'LAYOFF',
|
||||
'LAYON',
|
||||
'LAYFRZ',
|
||||
'LAYTHW',
|
||||
'LAYLCK',
|
||||
'LAYULK',
|
||||
'LAYISO',
|
||||
'LAYUNISO',
|
||||
'LAYERP',
|
||||
'LAYERSTATE',
|
||||
'LAYERSTATERESTORE',
|
||||
'LAYWALK',
|
||||
// 파일 — 내보내기와 클립보드 복사는 도면을 바꾸지 않는다
|
||||
'EXPORT',
|
||||
'EXPORTSVG',
|
||||
'EXPORTPNG',
|
||||
'COPYCLIP',
|
||||
'COPYBASE',
|
||||
]);
|
||||
|
||||
/** 확정한 도면에서 이 명령을 실행해도 되는가. */
|
||||
export const isViewOnlyCommand = (command: CadCommand): boolean =>
|
||||
VIEW_ONLY_COMMAND_IDS.has(command.id);
|
||||
|
||||
export const getAllCommands = (): CadCommand[] => ALL_COMMANDS;
|
||||
|
||||
export const getCommandById = (id: string): CadCommand | undefined => COMMANDS_BY_ID.get(id);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { Actor } from 'xstate';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { getSelectedEntities, setActiveToolActor } from '../state';
|
||||
import { getSelectedEntities, isDrawingReadOnly, setActiveToolActor } from '../state';
|
||||
import type { CadCommand } from './command.types';
|
||||
import { getCommandById, resolveCommandInput } from './registry';
|
||||
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
|
||||
|
||||
const COMMAND_HISTORY_LIMIT = 200;
|
||||
|
||||
@@ -24,6 +24,13 @@ function log(line: string) {
|
||||
|
||||
/** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */
|
||||
export function runCommand(command: CadCommand): string {
|
||||
// 확정한 도면은 읽기 전용이다 — 보는 명령만 통과시킨다. 리본·명령행·단축키가 모두
|
||||
// 이 한 곳을 지나므로 여기서 한 번 막으면 새는 길이 없다 (2026-09-01 사용자 확정).
|
||||
if (isDrawingReadOnly() && !isViewOnlyCommand(command)) {
|
||||
toast.info(`확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요. (${command.label})`);
|
||||
log(`${command.id}: 확정 도면 — 실행하지 않음`);
|
||||
return '';
|
||||
}
|
||||
if (command.needsSelection && getSelectedEntities().length === 0) {
|
||||
toast.info(`${command.label}: 객체를 먼저 선택하십시오.`);
|
||||
log(`${command.id}: 선택 없음`);
|
||||
|
||||
@@ -44,13 +44,15 @@ export const LayerManager: FC<LayerManagerProps> = ({
|
||||
for (const entity of selectedEntities) {
|
||||
entity.layerId = layerId;
|
||||
}
|
||||
console.info(`Assigned ${selectedEntities.length} entities to layer`);
|
||||
// 객체를 옮기는 것도 도면 편집이다 — 되돌리기·미저장 표시에 실어야 한다.
|
||||
setEntities([...getEntities()], true);
|
||||
};
|
||||
|
||||
const handleDeleteLayer = (evt: MouseEvent, layerId: string): void => {
|
||||
evt.stopPropagation();
|
||||
const entitiesNotOnLayer = getEntities().filter((entity) => entity.layerId !== layerId);
|
||||
setEntities(entitiesNotOnLayer);
|
||||
// 도면층을 지우면 그 위의 객체가 통째로 사라진다 — 되돌리기에 실어야 Ctrl+Z로 돌아온다.
|
||||
setEntities(entitiesNotOnLayer, true);
|
||||
setLayers(getLayers().filter((layer) => layer.id !== layerId));
|
||||
if (getActiveLayerId() === layerId) {
|
||||
setActiveLayerId(getLayers()[0].id);
|
||||
|
||||
@@ -36,16 +36,31 @@ export const dashToLineType = (dash: number[] | undefined): string =>
|
||||
LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid';
|
||||
|
||||
/** 선택 객체가 있으면 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */
|
||||
function applyToSelection(mutate: (entity: Entity) => void): boolean {
|
||||
function applyToSelection(mutate: (entity: Entity) => void, trackInUndoStack = true): boolean {
|
||||
const selected = getSelectedEntities();
|
||||
if (!selected.length) return false;
|
||||
for (const entity of selected) {
|
||||
mutate(entity);
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setEntities([...getEntities()], trackInUndoStack);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 색 선택기는 드래그하는 내내 값 변경을 쏜다. 그 하나하나를 되돌리기·변경통지에 실으면
|
||||
* 색 한 번 고르는 동안 수십 건이 나간다(2026-09-01 지적). 끄는 동안에는 화면만 칠하고,
|
||||
* 멎은 뒤 한 번만 되돌리기·통지에 싣는다.
|
||||
*/
|
||||
const COLOR_COMMIT_DELAY_MS = 350;
|
||||
let colorCommitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function paintNowCommitLater(mutate: (entity: Entity) => void): void {
|
||||
applyToSelection(mutate, false); // 화면만 — 되돌리기·미저장 표시는 아직
|
||||
clearTimeout(colorCommitTimer);
|
||||
colorCommitTimer = setTimeout(() => {
|
||||
applyToSelection(() => {}, true); // 멎었다 — 이번 한 번만 실어 보낸다
|
||||
}, COLOR_COMMIT_DELAY_MS);
|
||||
}
|
||||
|
||||
export const PropertiesWidget: FC = () => {
|
||||
const lineColor = getActiveLineColor();
|
||||
const lineWidth = getActiveLineWidth();
|
||||
@@ -59,9 +74,10 @@ export const PropertiesWidget: FC = () => {
|
||||
type="color"
|
||||
value={lineColor}
|
||||
onChange={(event) => {
|
||||
setActiveLineColor(event.target.value);
|
||||
applyToSelection((entity) => {
|
||||
entity.lineColor = event.target.value;
|
||||
const color = event.target.value;
|
||||
setActiveLineColor(color);
|
||||
paintNowCommitLater((entity) => {
|
||||
entity.lineColor = color;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -145,13 +161,16 @@ export const LayersWidget: FC = () => {
|
||||
export const TextStyleWidget: FC = () => {
|
||||
const textStyle = getActiveTextStyle();
|
||||
|
||||
const handle = (patch: Partial<typeof textStyle>) => {
|
||||
const handle = (patch: Partial<typeof textStyle>, live = false) => {
|
||||
setActiveTextStyle(patch);
|
||||
applyToSelection((entity) => {
|
||||
const mutate = (entity: Entity) => {
|
||||
if (entity.getType() === EntityName.Text) {
|
||||
(entity as TextEntity).setTextOptions(patch);
|
||||
}
|
||||
});
|
||||
};
|
||||
// 색만 드래그 중 연발한다 — 나머지 항목은 한 번 고르면 끝이라 바로 싣는다.
|
||||
if (live) paintNowCommitLater(mutate);
|
||||
else applyToSelection(mutate);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -203,7 +222,7 @@ export const TextStyleWidget: FC = () => {
|
||||
<input
|
||||
type="color"
|
||||
value={textStyle.textColor}
|
||||
onChange={(event) => handle({ textColor: event.target.value })}
|
||||
onChange={(event) => handle({ textColor: event.target.value }, true)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -24,10 +24,10 @@ export const Toolbar: FC = () => {
|
||||
const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null;
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-panel-width',
|
||||
panelCollapsed ? '0px' : '248px'
|
||||
);
|
||||
// 편 상태의 폭은 App.css의 `--cad-panel-width` 기본값을 쓴다. 여기 숫자를 박아
|
||||
// 두면 스타일에서 넓혀도 이 인라인 값이 덮는다(2026-09-01 실측: 300px 무시됨).
|
||||
if (panelCollapsed) document.documentElement.style.setProperty('--cad-panel-width', '0px');
|
||||
else document.documentElement.style.removeProperty('--cad-panel-width');
|
||||
document.documentElement.style.setProperty(
|
||||
'--cad-command-height',
|
||||
commandLineVisible ? '86px' : '0px'
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
import { toast } from 'react-toastify';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import type { Entity } from '../entities/Entity';
|
||||
import { getEntities, resetUndoBaseline, setActiveLayerId, setEntities, setLayers } from '../state';
|
||||
import {
|
||||
getEntities,
|
||||
isDrawingReadOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setEntities,
|
||||
setLayers,
|
||||
} from '../state';
|
||||
import { exportEntitiesAndLayersToJsonString } from './import-export-handlers/export-entities-to-json';
|
||||
import { getEntitiesAndLayersFromJsonString } from './import-export-handlers/import-entities-from-json';
|
||||
|
||||
@@ -22,9 +29,37 @@ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** 마지막으로 백업한 객체 배열. setEntities가 매번 새 배열을 만들어 참조로 비교된다 */
|
||||
let lastBackedUp: Entity[] | null = null;
|
||||
|
||||
/**
|
||||
* 지금 백업이 어느 도면 것인가. 칸을 하나만 쓰면 도면을 넘겨볼 때마다 서로 덮어
|
||||
* 마지막에 본 것만 남고, 되살리면 **지금 열린 다른 도면 위에 붙는다**
|
||||
* (2026-09-01 지적). B07 임베드에서는 도면을 실을 때마다 이 값이 바뀐다.
|
||||
*/
|
||||
let recoveryScope: string | null = null;
|
||||
/** 서버 도면을 싣는 동안에는 백업하지 않는다 — 적재가 사용자 백업을 덮어 쓴다. */
|
||||
let suspended = false;
|
||||
|
||||
function recoveryKey(): string {
|
||||
return recoveryScope ? `${RECOVERY_KEY}__${recoveryScope}` : RECOVERY_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 칸을 이 도면 것으로 옮긴다 (B07 브리지가 도면을 실을 때 부른다).
|
||||
* 옮기는 동안은 백업을 멈춘다 — 방금 실은 서버 도면이 사용자 백업을 덮지 않게.
|
||||
*/
|
||||
export function setRecoveryScope(drawingId: string | null): void {
|
||||
recoveryScope = drawingId;
|
||||
lastBackedUp = null;
|
||||
suspended = true;
|
||||
clearTimeout(debounceTimer);
|
||||
// 적재가 부르는 상태 갱신이 모두 지나간 뒤 다시 연다.
|
||||
setTimeout(() => {
|
||||
suspended = false;
|
||||
}, AUTOSAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function readRecovery(): RecoveryFile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECOVERY_KEY);
|
||||
const raw = localStorage.getItem(recoveryKey());
|
||||
return raw ? (JSON.parse(raw) as RecoveryFile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -33,13 +68,13 @@ function readRecovery(): RecoveryFile | null {
|
||||
|
||||
async function writeBackup(): Promise<void> {
|
||||
const entities = getEntities();
|
||||
if (entities === lastBackedUp || entities.length === 0) return;
|
||||
if (suspended || entities === lastBackedUp || entities.length === 0) return;
|
||||
try {
|
||||
const file: RecoveryFile = {
|
||||
savedAt: Date.now(),
|
||||
json: await exportEntitiesAndLayersToJsonString(),
|
||||
};
|
||||
localStorage.setItem(RECOVERY_KEY, JSON.stringify(file));
|
||||
localStorage.setItem(recoveryKey(), JSON.stringify(file));
|
||||
lastBackedUp = entities;
|
||||
} catch {
|
||||
// 저장 공간이 없거나 막혀 있으면 조용히 넘어간다 — 편집을 막을 일은 아니다
|
||||
@@ -48,13 +83,17 @@ async function writeBackup(): Promise<void> {
|
||||
|
||||
export function clearRecovery(): void {
|
||||
try {
|
||||
localStorage.removeItem(RECOVERY_KEY);
|
||||
localStorage.removeItem(recoveryKey());
|
||||
} catch {
|
||||
// 지우지 못해도 다음 백업이 덮어쓴다
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreRecovery(): Promise<void> {
|
||||
if (isDrawingReadOnly()) {
|
||||
toast.info('확정한 도면입니다. 되살리려면 [수정]을 먼저 누르세요.');
|
||||
return;
|
||||
}
|
||||
const file = readRecovery();
|
||||
if (!file) {
|
||||
toast.info('복구할 백업이 없습니다.');
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
* 화면 검증용 상태 조회 창구 — 브라우저 콘솔·자동화 스크립트가
|
||||
* window.__aisloCad로 현재 객체·도면층·선택을 읽는다. 앱 동작에는 관여하지 않는다.
|
||||
*/
|
||||
import { getEntities, getLayers, getSelectedEntityIds, getSnapPoint } from '../state';
|
||||
import {
|
||||
getDesignMeta,
|
||||
getEntities,
|
||||
getHighlightedEntityIds,
|
||||
getLayers,
|
||||
getSelectedEntityIds,
|
||||
getSnapPoint,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
} from '../state';
|
||||
import { isEntityHidden } from './visibility';
|
||||
|
||||
export function registerCadDebugHook(): void {
|
||||
@@ -30,5 +39,11 @@ export function registerCadDebugHook(): void {
|
||||
selection: () => getSelectedEntityIds(),
|
||||
// 마우스 위치의 객체 스냅점 — 잠금 도면층(도각)이 스냅에 끼는지 검증할 때 읽는다.
|
||||
snap: () => getSnapPoint(),
|
||||
// 마우스가 스친 객체 — 잠금층(도각·등고선)이 밝아지는지 수치로 본다.
|
||||
highlighted: () => getHighlightedEntityIds(),
|
||||
// 확정 도면의 편집 잠금과 미저장 표시 — 화면 검증이 상태를 직접 읽는다.
|
||||
readOnly: () => isDrawingReadOnly(),
|
||||
dirty: () => isDrawingDirty(),
|
||||
meta: () => getDesignMeta(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,17 +5,23 @@ import type { JsonDrawingFileSerialized } from '../helpers/import-export-handler
|
||||
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
||||
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json.ts';
|
||||
import {
|
||||
clearDrawingDirty,
|
||||
getCanvas,
|
||||
getDesignMeta,
|
||||
getEntities,
|
||||
getLayers,
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
import { runCommandInput } from '../commands/run-command.ts';
|
||||
import { setRecoveryScope } from '../helpers/autosave.ts';
|
||||
|
||||
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
|
||||
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
|
||||
@@ -52,19 +58,25 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') {
|
||||
notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction });
|
||||
}
|
||||
|
||||
/** 수량표 값 편집을 부모에 알린다 (확정 상태 롤백 연동). */
|
||||
export function notifyDrawingChangedByTable() {
|
||||
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
|
||||
}
|
||||
/**
|
||||
* 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다
|
||||
* (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서
|
||||
* 화면과 저장값이 어긋났다.
|
||||
*/
|
||||
const QUANTITY_TABLE_LAYER_ID = 'b08-cross-table';
|
||||
|
||||
/**
|
||||
* 더블클릭한 위치의 텍스트(잠금 해제 레이어)를 찾아 내용을 즉시 편집한다.
|
||||
* CAD 테이블(측점 테이블·수량 산출표) 값 셀 수정 수단 — 잠금 레이어는 제외.
|
||||
* CAD 측점 테이블 값 셀 수정 수단 — 잠금 레이어와 수량 산출표는 제외.
|
||||
*/
|
||||
function registerTextDoubleClickEdit() {
|
||||
const canvas = getCanvas();
|
||||
if (!canvas) return;
|
||||
canvas.addEventListener('dblclick', (event: MouseEvent) => {
|
||||
if (isDrawingReadOnly()) {
|
||||
toast.info('확정한 도면입니다. 고치려면 [수정]을 먼저 누르세요.');
|
||||
return;
|
||||
}
|
||||
const drawController = getScreenCanvasDrawController();
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY);
|
||||
@@ -76,18 +88,24 @@ function registerTextDoubleClickEdit() {
|
||||
);
|
||||
let closest: TextEntity | null = null;
|
||||
let closestDistance = Number.MAX_SAFE_INTEGER;
|
||||
let closestOnQuantityTable = false;
|
||||
for (const entity of getEntities()) {
|
||||
if (!(entity instanceof TextEntity) || lockedLayerIds.has(entity.layerId)) continue;
|
||||
const distanceInfo = entity.distanceTo(worldPoint);
|
||||
if (distanceInfo && distanceInfo[0] < closestDistance) {
|
||||
closestDistance = distanceInfo[0];
|
||||
closest = entity;
|
||||
closestOnQuantityTable = entity.layerId === QUANTITY_TABLE_LAYER_ID;
|
||||
}
|
||||
}
|
||||
if (!closest) return;
|
||||
const fontSize = closest.getTextOptions().fontSize;
|
||||
const tolerance = Math.max((closest.getLabel().length + 2) * fontSize * 0.5, fontSize * 2);
|
||||
if (closestDistance > tolerance) return;
|
||||
if (closestOnQuantityTable) {
|
||||
toast.info('수량표는 횡단설계(B06)에서 고칩니다. 고치고 돌아오면 반영됩니다.');
|
||||
return;
|
||||
}
|
||||
const nextLabel = window.prompt('값 수정', closest.getLabel());
|
||||
if (nextLabel === null || nextLabel === closest.getLabel()) return;
|
||||
closest.setLabel(nextLabel);
|
||||
@@ -108,8 +126,9 @@ export function registerAisloDrawingBridge() {
|
||||
const drawing = JSON.parse(
|
||||
await exportEntitiesAndLayersToJsonString()
|
||||
) as JsonDrawingFileSerialized;
|
||||
// 편집된 수량표 값을 도면과 함께 부모로 돌려준다.
|
||||
// 수량표는 앞 단계 산출값을 그대로 돌려준다 — B07에서는 고치지 않는다.
|
||||
const quantityTable = getDesignMeta()?.quantityTable ?? null;
|
||||
clearDrawingDirty(); // 부모가 받아 저장한다 — 미저장 경고 대상이 아니다
|
||||
notifyParent(AISLO_DRAWING_SAVE_RESPONSE_MESSAGE, { drawing, quantityTable });
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : 'Unable to serialize drawing';
|
||||
@@ -125,9 +144,17 @@ export function registerAisloDrawingBridge() {
|
||||
setLayers(drawing.layers);
|
||||
setActiveLayerId(drawing.layers[0].id);
|
||||
// 실은 도면이 되돌리기의 바닥 — 첫 Ctrl+Z가 백지로 가지 않게 한다.
|
||||
// (미저장 표시도 여기서 내려간다 — 방금 서버 도면을 실었으니 고친 것이 없다.)
|
||||
resetUndoBaseline();
|
||||
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
|
||||
setDesignMeta(event.data.meta ?? null);
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
// 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중
|
||||
// 상태를 이어 갈 이유도 없다.
|
||||
runCommandInput('SELECT');
|
||||
// 자동백업을 이 도면 칸으로 옮긴다 — 안 옮기면 백업 한 칸을 서로 덮는다.
|
||||
setRecoveryScope(event.data.meta?.drawingId ?? null);
|
||||
getScreenCanvasDrawController().zoomToFitScreen();
|
||||
notifyParent(AISLO_DRAWING_LOADED_MESSAGE);
|
||||
} catch (error) {
|
||||
@@ -135,8 +162,10 @@ export function registerAisloDrawingBridge() {
|
||||
notifyParent(AISLO_DRAWING_ERROR_MESSAGE, { detail });
|
||||
}
|
||||
});
|
||||
// 부모는 이 통지로 **미저장 여부만** 안다. 확정을 푸는 것은 [수정] 버튼 한 곳뿐이다
|
||||
// (2026-09-01 사용자 확정) — 예전에는 되돌리기·색 고르기까지 확정을 풀었다.
|
||||
window.addEventListener(HtmlEvent.DRAWING_CHANGED, () => {
|
||||
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE);
|
||||
notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() });
|
||||
});
|
||||
registerTextDoubleClickEdit();
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { DesignMeta } from './App.types';
|
||||
import type { Entity } from './entities/Entity';
|
||||
import {
|
||||
clearDrawingDirty,
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getHighlightedEntityIds,
|
||||
isDrawingDirty,
|
||||
isDrawingReadOnly,
|
||||
resetUndoBaseline,
|
||||
setActiveLayerId,
|
||||
setDesignMeta,
|
||||
setEntities,
|
||||
setHighlightedEntityIds,
|
||||
setLayers,
|
||||
undo,
|
||||
} from './state';
|
||||
@@ -17,6 +24,24 @@ const layer = (id: string, isLocked: boolean) => ({
|
||||
isLocked,
|
||||
});
|
||||
|
||||
/** 노드 환경에는 window가 없다 — 상태 알림을 받는 시늉만 시킨다. */
|
||||
const stubWindow = () => {
|
||||
(globalThis as { window?: unknown }).window = { dispatchEvent: () => true };
|
||||
};
|
||||
|
||||
const entityOnLayer = (id: string, layerId: string) => ({ id, layerId }) as unknown as Entity;
|
||||
|
||||
const meta = (confirmed: boolean): DesignMeta => ({
|
||||
kind: 'cross',
|
||||
drawingId: 'cross_s00020m',
|
||||
title: '1장',
|
||||
info: '',
|
||||
confirmed,
|
||||
quantityTable: null,
|
||||
hasPrev: false,
|
||||
hasNext: false,
|
||||
});
|
||||
|
||||
describe('setActiveLayerId', () => {
|
||||
it('잠금 도면층(배경)은 현재 도면층이 되지 않고 잠금 아닌 첫 층으로 간다', () => {
|
||||
setLayers([layer('도각', true), layer('계획선', false)]);
|
||||
@@ -32,10 +57,7 @@ describe('setActiveLayerId', () => {
|
||||
});
|
||||
|
||||
describe('resetUndoBaseline', () => {
|
||||
// 상태 변경 알림만 window로 나간다 — 노드 환경이라 받는 시늉만 시킨다.
|
||||
beforeAll(() => {
|
||||
(globalThis as { window?: unknown }).window = { dispatchEvent: () => true };
|
||||
});
|
||||
beforeAll(stubWindow);
|
||||
|
||||
it('기준선을 세운 뒤의 되돌리기는 실은 도면까지만 물러난다', () => {
|
||||
const loaded = [{ id: 'a' } as unknown as Entity];
|
||||
@@ -46,3 +68,55 @@ describe('resetUndoBaseline', () => {
|
||||
expect(getEntities()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setHighlightedEntityIds', () => {
|
||||
it('잠금 도면층(도각·등고선) 객체는 마우스가 가도 밝아지지 않는다', () => {
|
||||
setLayers([layer('도각', true), layer('계획선', false)]);
|
||||
setEntities([entityOnLayer('배경', '도각'), entityOnLayer('내선', '계획선')], false);
|
||||
setHighlightedEntityIds(['배경', '내선']);
|
||||
expect(getHighlightedEntityIds()).toEqual(['내선']);
|
||||
});
|
||||
|
||||
it('잠금층만 가리키면 아무것도 밝아지지 않는다', () => {
|
||||
setLayers([layer('도각', true)]);
|
||||
setEntities([entityOnLayer('배경', '도각')], false);
|
||||
setHighlightedEntityIds(['배경']);
|
||||
expect(getHighlightedEntityIds()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('미저장 표시(drawingDirty)', () => {
|
||||
beforeAll(stubWindow);
|
||||
|
||||
it('편집하면 서고, 도면을 새로 실으면(기준선 재설정) 내려간다', () => {
|
||||
setEntities([entityOnLayer('a', 'L')], false);
|
||||
resetUndoBaseline();
|
||||
expect(isDrawingDirty()).toBe(false);
|
||||
setEntities([entityOnLayer('a', 'L'), entityOnLayer('b', 'L')], true);
|
||||
expect(isDrawingDirty()).toBe(true);
|
||||
resetUndoBaseline();
|
||||
expect(isDrawingDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('되돌리기도 미저장이다 — 저장본과 달라질 수 있다', () => {
|
||||
setEntities([entityOnLayer('a', 'L')], false);
|
||||
resetUndoBaseline();
|
||||
setEntities([entityOnLayer('a', 'L'), entityOnLayer('b', 'L')], true);
|
||||
clearDrawingDirty();
|
||||
undo();
|
||||
expect(isDrawingDirty()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDrawingReadOnly', () => {
|
||||
beforeAll(stubWindow);
|
||||
|
||||
it('확정한 도면은 읽기 전용, 확정을 풀면 편집이 열린다', () => {
|
||||
setDesignMeta(meta(true));
|
||||
expect(isDrawingReadOnly()).toBe(true);
|
||||
setDesignMeta(meta(false));
|
||||
expect(isDrawingReadOnly()).toBe(false);
|
||||
setDesignMeta(null);
|
||||
expect(isDrawingReadOnly()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,8 +126,11 @@ let lastDrawTimestamp: DOMHighResTimeStamp = 0;
|
||||
|
||||
/**
|
||||
* Active line color (7-char hex so <input type="color"> can consume it directly)
|
||||
*
|
||||
* 종이 배경(흰색)에 그리므로 검정이 기본이다. 원본 OpenWebCAD의 흰색을 그대로 두면
|
||||
* 그은 선도 색 스와치도 흰 바탕에 묻혀 보이지 않는다(2026-09-01 실측).
|
||||
*/
|
||||
let activeLineColor = '#ffffff';
|
||||
let activeLineColor = '#000000';
|
||||
|
||||
/**
|
||||
* Active line width
|
||||
@@ -145,7 +148,7 @@ let activeLineDash: number[] | undefined = undefined;
|
||||
let activeTextStyle = {
|
||||
fontFamily: 'Noto Sans KR',
|
||||
fontSize: 16,
|
||||
textColor: '#ffffff',
|
||||
textColor: '#000000', // 흰 종이 배경 — activeLineColor와 같은 이유로 검정이 기본이다
|
||||
bold: false,
|
||||
italic: false,
|
||||
};
|
||||
@@ -184,6 +187,13 @@ let snapTrackingEnabled = true;
|
||||
*/
|
||||
let designMeta: DesignMeta | null = null;
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
* 없으면 사용자가 그은 선이 경고 없이 사라진다(2026-09-01 실측).
|
||||
* 도면 적재와 저장 응답에서 내려간다.
|
||||
*/
|
||||
let drawingDirty = false;
|
||||
|
||||
// getters
|
||||
export const getCanvas = () => canvas;
|
||||
export const getActiveToolActor = () => activeToolActor;
|
||||
@@ -246,6 +256,12 @@ export const getSnapEnabled = () => snapEnabled;
|
||||
export const getGridEnabled = () => gridEnabled;
|
||||
export const getSnapTrackingEnabled = () => snapTrackingEnabled;
|
||||
export const getDesignMeta = (): DesignMeta | null => designMeta;
|
||||
export const isDrawingDirty = () => drawingDirty;
|
||||
/**
|
||||
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
|
||||
* 확정). 보기(확대·이동·도면층 켜기끄기)는 그대로 두고, 푸는 길은 부모의 [수정]뿐이다.
|
||||
*/
|
||||
export const isDrawingReadOnly = (): boolean => designMeta?.confirmed === true;
|
||||
|
||||
// setters
|
||||
export const setCanvas = (newCanvas: HTMLCanvasElement) => {
|
||||
@@ -304,10 +320,21 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
|
||||
entities = newEntities;
|
||||
bumpSceneVersion();
|
||||
if (trackInUndoStack) {
|
||||
drawingDirty = true;
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
};
|
||||
/** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */
|
||||
export const clearDrawingDirty = () => {
|
||||
drawingDirty = false;
|
||||
};
|
||||
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
|
||||
// 잠금 도면층(도각·등고선·계류·원지반)은 배경이다 — 마우스가 스쳐도 밝아지지 않는다
|
||||
// (2026-09-01 사용자 지시). 거르는 자리를 여기 한 곳에 둬 호출부가 늘어도 새지 않게 한다.
|
||||
const locked = new Set(
|
||||
entities.filter((entity) => layersById.get(entity.layerId)?.isLocked).map((entity) => entity.id)
|
||||
);
|
||||
if (locked.size) newEntityIds = newEntityIds.filter((id) => !locked.has(id));
|
||||
highlightedEntityIds = newEntityIds;
|
||||
highlightedEntityIdSet = new Set(newEntityIds);
|
||||
};
|
||||
@@ -453,12 +480,9 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
designMeta = newMeta;
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
/** 사용자가 편집한 수량표 전체를 반영하고 확정 상태를 롤백한다 (저장 대상). */
|
||||
export const setDesignQuantityTable = (table: Record<string, number | null>) => {
|
||||
if (!designMeta) return;
|
||||
designMeta = { ...designMeta, quantityTable: table, confirmed: false };
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
|
||||
// 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은
|
||||
// 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다.
|
||||
|
||||
// Computed setters
|
||||
export const deleteEntities = (entitiesToDelete: Entity[], trackInUndoStack: boolean): Entity[] => {
|
||||
@@ -523,6 +547,7 @@ function updateStates(undoState: UndoState) {
|
||||
export function resetUndoBaseline() {
|
||||
undoStack.clear(StateVariable.entities);
|
||||
undoStack.push({ variable: StateVariable.entities, value: entities });
|
||||
drawingDirty = false;
|
||||
}
|
||||
|
||||
export function undo() {
|
||||
@@ -530,6 +555,7 @@ export function undo() {
|
||||
if (!undoState) return;
|
||||
|
||||
updateStates(undoState);
|
||||
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
@@ -538,6 +564,7 @@ export function redo() {
|
||||
if (!redoState) return;
|
||||
|
||||
updateStates(redoState);
|
||||
drawingDirty = true;
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user