merge(B07): desktop-sub 캐드 편집 유실 방지·도면 잠금 통합 (PR #8)

편집 유실 방지와 확정 도면 잠금.
This commit was merged in pull request #8.
This commit is contained in:
2026-09-01 20:13:59 +09:00
22 changed files with 666 additions and 105 deletions
+13 -2
View File
@@ -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"))
@@ -361,7 +377,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"
@@ -390,6 +413,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
@@ -577,6 +604,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
+4 -2
View File
@@ -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;
+125 -48
View File
@@ -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 (
+40 -1
View File
@@ -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;
+22 -2
View File
@@ -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);
+4 -1
View File
@@ -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();
+78 -4
View File
@@ -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);
});
});
+35 -8
View File
@@ -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));
}