Compare commits
4
Commits
61eb0bc4e6
...
c0eb280c32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0eb280c32 | ||
|
|
d6302a9695 | ||
|
|
0185546c5c | ||
|
|
b87bac729b |
@@ -1,14 +1,23 @@
|
||||
/** 제목표시줄 + 빠른 실행 도구막대 (AutoCAD 상단 막대) */
|
||||
import type { FC } from 'react';
|
||||
import { type FC, useEffect, useState } from 'react';
|
||||
import { HtmlEvent } from '../App.types';
|
||||
import { getCommandById } from '../commands/registry';
|
||||
import { runCommand } from '../commands/run-command';
|
||||
import { QUICK_ACCESS_COMMANDS } from '../ribbon/ribbon.config';
|
||||
import { getHostTitle } from '../state';
|
||||
|
||||
export const QuickAccessBar: FC = () => (
|
||||
export const QuickAccessBar: FC = () => {
|
||||
const [hostTitle, setTitle] = useState(getHostTitle());
|
||||
useEffect(() => {
|
||||
const refresh = () => setTitle(getHostTitle());
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, []);
|
||||
return (
|
||||
<header className="cad-titlebar controls">
|
||||
<div className="cad-brand">
|
||||
<strong>Aislo CAD</strong>
|
||||
<span>B07 상세 설계</span>
|
||||
<span>{hostTitle}</span>
|
||||
</div>
|
||||
<div className="cad-quick-access">
|
||||
{QUICK_ACCESS_COMMANDS.map((id) => {
|
||||
@@ -32,3 +41,4 @@ export const QuickAccessBar: FC = () => (
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
setEntities,
|
||||
setFrameEditMode,
|
||||
setHostReadOnly,
|
||||
setHostTitle,
|
||||
setLayers,
|
||||
} from '../state.ts';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -50,6 +51,8 @@ interface DrawingLoadMessage {
|
||||
recoveryScope?: string;
|
||||
/** 보기 전용으로 싣는가 — 확정본처럼 그리기·수정을 막는다. */
|
||||
readOnly?: boolean;
|
||||
/** 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
|
||||
hostTitle?: string;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -193,6 +196,7 @@ export function registerAisloDrawingBridge() {
|
||||
setDesignMeta(event.data.meta ?? null);
|
||||
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
|
||||
setHostReadOnly(event.data.readOnly === true);
|
||||
setHostTitle(event.data.hostTitle ?? 'B07 상세 설계');
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
|
||||
@@ -192,6 +192,7 @@ let frameEditMode = false;
|
||||
let frameFields: Record<string, string> = {};
|
||||
/** 부모가 보기 전용으로 실었는가 — M02 에서 고칠 권한이 없는 양식을 볼 때. */
|
||||
let hostReadOnly = false;
|
||||
let hostTitle = 'B07 상세 설계';
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -496,6 +497,12 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
};
|
||||
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
|
||||
/** 부모가 실은 도면을 보기 전용으로 둔다 — 확정본과 같은 막힘(도면을 실을 때마다 새로 정함). */
|
||||
/** 제목표시줄 부제 — 부모가 도면을 실을 때 정함(없으면 B07 기본 글). */
|
||||
export const getHostTitle = (): string => hostTitle;
|
||||
export const setHostTitle = (title: string) => {
|
||||
hostTitle = title;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
export const setHostReadOnly = (readOnly: boolean) => {
|
||||
hostReadOnly = readOnly;
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
|
||||
@@ -24,12 +24,42 @@ export interface DrawingTemplateDoc extends CadHostDrawing {
|
||||
}
|
||||
|
||||
export interface DrawingTemplateOptions {
|
||||
/** [저장] 을 누르면 편집본을 넘긴다. 없으면 [저장] 단추를 두지 않는다. */
|
||||
/** 쓰지 않음 — 저장은 페이지 머리 [저장] 한 곳(`getDoc()` 으로 편집본을 가져감 · 판 · 409 흐름). */
|
||||
onSave?: (doc: DrawingTemplateDoc) => void | Promise<void>;
|
||||
/** 보기 전용 — CAD 그리기·수정 · 작도 영역 · 불러오기가 막힌다. */
|
||||
readOnly?: boolean;
|
||||
/** 양식 이름 — CAD 자동백업 칸을 양식마다 나눈다(B07 도면 백업과도 안 겹침). */
|
||||
name?: string;
|
||||
/** 양식 층 — 같은 이름도 층마다 백업 칸이 갈린다(`m02:층:이름`). */
|
||||
layer?: string;
|
||||
/** 프로젝트 층이면 프로젝트 id — 칸이 `m02:project:프로젝트id:이름` 이 된다. */
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__";
|
||||
|
||||
/** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */
|
||||
function recoveryScopeOf(options: DrawingTemplateOptions): string {
|
||||
const name = options.name ?? "drawing";
|
||||
if (!options.layer) return `m02:${name}`;
|
||||
const project = options.layer === "project" ? `:${options.projectId ?? ""}` : "";
|
||||
return `m02:${options.layer}${project}:${name}`;
|
||||
}
|
||||
|
||||
/** 층 없는 옛 칸(`m02:이름`)을 새 칸으로 한 번 옮기고 지운다 — 새 칸에 이미 있으면 그대로 두고 지움. */
|
||||
function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): void {
|
||||
const old = `${RECOVERY_PREFIX}m02:${options.name ?? "drawing"}`;
|
||||
if (!options.layer || old === `${RECOVERY_PREFIX}${scope}`) return;
|
||||
try {
|
||||
const value = localStorage.getItem(old);
|
||||
if (value === null) return;
|
||||
if (localStorage.getItem(`${RECOVERY_PREFIX}${scope}`) === null) {
|
||||
localStorage.setItem(`${RECOVERY_PREFIX}${scope}`, value);
|
||||
}
|
||||
localStorage.removeItem(old);
|
||||
} catch {
|
||||
// 저장소를 못 써도 편집은 됨
|
||||
}
|
||||
}
|
||||
|
||||
export interface DrawingTemplateHandle {
|
||||
@@ -79,7 +109,8 @@ export function mountDrawingTemplate(
|
||||
options: DrawingTemplateOptions = {},
|
||||
): DrawingTemplateHandle {
|
||||
const readOnly = options.readOnly === true;
|
||||
const recoveryScope = `m02:${options.name ?? "drawing"}`;
|
||||
const recoveryScope = recoveryScopeOf(options);
|
||||
migrateOldRecovery(options, recoveryScope);
|
||||
let base: DrawingTemplateDoc = doc;
|
||||
|
||||
const root = document.createElement("div");
|
||||
@@ -139,7 +170,13 @@ export function mountDrawingTemplate(
|
||||
const cad = createCadHost<DrawingTemplateDoc>({ title: "도면 양식" });
|
||||
const load = (drawing: DrawingTemplateDoc): void => {
|
||||
cad.beginLoading();
|
||||
cad.load(drawing, null, !readOnly, {}, { recoveryScope, readOnly });
|
||||
cad.load(
|
||||
drawing,
|
||||
null,
|
||||
!readOnly,
|
||||
{},
|
||||
{ recoveryScope, readOnly, hostTitle: "도면 양식 편집" },
|
||||
);
|
||||
};
|
||||
|
||||
const actions = document.createElement("div");
|
||||
@@ -184,27 +221,6 @@ export function mountDrawingTemplate(
|
||||
return base;
|
||||
};
|
||||
|
||||
if (options.onSave && !readOnly) {
|
||||
const onSave = options.onSave;
|
||||
const saveButton = createButton({
|
||||
label: "저장",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
saveButton.disabled = true;
|
||||
getDoc()
|
||||
.then((next) => onSave(next))
|
||||
.catch((error) =>
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "양식을 저장하지 못했습니다.",
|
||||
"error",
|
||||
),
|
||||
)
|
||||
.finally(() => (saveButton.disabled = false));
|
||||
},
|
||||
});
|
||||
actions.append(saveButton);
|
||||
}
|
||||
|
||||
toolbar.append(area, fields, actions);
|
||||
root.append(toolbar, cad.element);
|
||||
container.append(root);
|
||||
|
||||
@@ -252,13 +252,17 @@ async def save_layer_template(
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
folder = await _layer_dir(session, layer, project_id=project_id, write=True)
|
||||
document = body.문서
|
||||
if kind == "table" and fill.is_fillable(document):
|
||||
# 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음)
|
||||
document = fill.strip_design(document)
|
||||
try:
|
||||
version = await asyncio.to_thread(
|
||||
layers.write_template,
|
||||
folder,
|
||||
kind,
|
||||
name,
|
||||
body.문서,
|
||||
document,
|
||||
version=body.판,
|
||||
check_version=True,
|
||||
)
|
||||
|
||||
@@ -112,7 +112,13 @@ export function createMain(isAdmin: boolean): MainHandle {
|
||||
"mountDrawingTemplate",
|
||||
);
|
||||
if (mountDrawing) {
|
||||
editor = mountDrawing(host, doc, { onSave: () => void doSave(), readOnly });
|
||||
editor = mountDrawing(host, doc, {
|
||||
onSave: () => void doSave(),
|
||||
readOnly,
|
||||
name: at.name,
|
||||
layer: at.layer,
|
||||
projectId: at.projectId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
empty(L("M02_NoDrawing"));
|
||||
|
||||
@@ -36,6 +36,9 @@ FACILITY_TYPES = {
|
||||
"revetment": "revetment",
|
||||
}
|
||||
EMPTY_LABEL = "-"
|
||||
ORPHAN_NOTE = "설계에서 측점 없어짐"
|
||||
#: 채운 표에 실어 보내는 펼침 전 열 — 저장 때 빠진 규칙 열을 되살리는 데만 씀.
|
||||
RULE_COLUMNS_KEY = "양식열"
|
||||
_REF = re.compile(r"\[([^\]$@][^\]@]*)\]")
|
||||
|
||||
|
||||
@@ -354,7 +357,8 @@ def fill_document(
|
||||
doc = copy.deepcopy(document)
|
||||
variables = dict(doc.get("변수") or {})
|
||||
interval = _number(variables.get("측점간격_m")) or 20.0
|
||||
columns, made = _expand_columns(doc.get("열") or [], items, variables, _choices(types))
|
||||
rule_columns = _unexpand(doc.get("열") or [])
|
||||
columns, made = _expand_columns(rule_columns, items, variables, _choices(types))
|
||||
notes: list[str] = []
|
||||
|
||||
# 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔)
|
||||
@@ -411,7 +415,7 @@ def fill_document(
|
||||
notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈")
|
||||
|
||||
# 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄
|
||||
hand = {column["id"] for column in columns if column.get("손")}
|
||||
hand = _hand_ids(columns)
|
||||
old_rows = {row.get("id"): row for row in doc.get("줄") or []}
|
||||
fixed = [row for row in doc.get("줄") or [] if row.get("고정")]
|
||||
added = [row for row in doc.get("줄") or [] if row.get("손") and not row.get("고정")]
|
||||
@@ -438,13 +442,78 @@ def fill_document(
|
||||
}
|
||||
if values:
|
||||
sta = (row.get("값") or {}).get("sta")
|
||||
added.append({"id": row["id"], "값": {"sta": sta, **values}, "손": True})
|
||||
memo = " · ".join(filter(None, [str(values.get("memo") or ""), ORPHAN_NOTE]))
|
||||
added.append({"id": row["id"], "값": {"sta": sta, **values, "memo": memo}, "손": True})
|
||||
doc["열"] = columns
|
||||
doc["줄"] = [*fixed, *design_rows, *added]
|
||||
doc["알림"] = notes
|
||||
doc[RULE_COLUMNS_KEY] = copy.deepcopy(rule_columns) # 저장 때 설계에 안 쓰인 펼침 열도 되살림
|
||||
return doc
|
||||
|
||||
|
||||
def _hand_ids(columns: list[dict[str, Any]]) -> set[str]:
|
||||
"""사람이 적는 열 — 바인딩 없는 열(손 열 · 비고 · 사용자가 더한 열) · 차례 · 측점 빼고."""
|
||||
return {
|
||||
column["id"]
|
||||
for column in columns
|
||||
if not column.get("바인딩") and column["id"] not in ("no", "sta")
|
||||
}
|
||||
|
||||
|
||||
def _restore_rules(
|
||||
columns: list[dict[str, Any]], rules: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""채울 때 설계 값이 없어 빠졌던 펼침 열을 원래 자리(앞 열 뒤)에 되살림."""
|
||||
out = list(columns)
|
||||
for index, rule in enumerate(rules):
|
||||
if not rule.get("펼침") or any(c["id"] == rule["id"] for c in out):
|
||||
continue
|
||||
before = [r["id"] for r in rules[:index]]
|
||||
at = max((i + 1 for i, c in enumerate(out) if c["id"] in before), default=0)
|
||||
out.insert(at, copy.deepcopy(rule))
|
||||
return out
|
||||
|
||||
|
||||
def strip_design(document: dict[str, Any]) -> dict[str, Any]:
|
||||
"""작업본에 둘 것만 — 양식(열 · 식 · 변수 · 머리) + 손 값 + 전구간 줄 · 사용자 줄.
|
||||
|
||||
채운 표가 [저장]으로 와도 설계값 · 펼친 열은 버림(펼침 규칙 열 하나로 되돌림) ·
|
||||
측점 줄은 손 값이 있을 때만 `{id(측점 키), sta, 손 값}` 으로 남김 —
|
||||
다시 채우면 같은 측점에 붙음.
|
||||
"""
|
||||
doc = copy.deepcopy(document)
|
||||
for key in ("알림", "결과"):
|
||||
doc.pop(key, None)
|
||||
columns = _restore_rules(_unexpand(doc.get("열") or []), doc.pop(RULE_COLUMNS_KEY, None) or [])
|
||||
hand = _hand_ids(columns)
|
||||
rows = []
|
||||
for row in doc.get("줄") or []:
|
||||
if row.get("고정"):
|
||||
rows.append(row)
|
||||
continue
|
||||
values = row.get("값") or {}
|
||||
kept = {k: v for k, v in values.items() if k in hand and v not in (None, "")}
|
||||
if row.get("손"):
|
||||
formulas = {k: v for k, v in (row.get("식") or {}).items() if k in hand}
|
||||
new = {**row, "값": {"sta": values.get("sta"), **kept}}
|
||||
new.pop("식", None)
|
||||
if formulas:
|
||||
new["식"] = formulas
|
||||
rows.append(new)
|
||||
elif kept:
|
||||
rows.append({"id": row["id"], "값": {"sta": values.get("sta"), **kept}})
|
||||
doc["열"] = columns
|
||||
doc["줄"] = rows
|
||||
return doc
|
||||
|
||||
|
||||
def is_fillable(document: Any) -> bool:
|
||||
"""설계값을 받는 표인가 — 바인딩 열이 하나라도 있으면."""
|
||||
return isinstance(document, dict) and any(
|
||||
isinstance(column, dict) and column.get("바인딩") for column in document.get("열") or []
|
||||
)
|
||||
|
||||
|
||||
def fill_table(
|
||||
project_root: str | Path,
|
||||
document: dict[str, Any],
|
||||
|
||||
@@ -148,3 +148,38 @@ def test_측점_표기() -> None:
|
||||
assert fill.station_label(38) == "NO.1+18"
|
||||
assert fill.station_label(59.999) == "NO.3"
|
||||
assert fill.station_label(2413.5) == "NO.120+13.5"
|
||||
|
||||
|
||||
def test_저장은_양식과_손_값만_다시_채우면_제자리(project: Path) -> None:
|
||||
master = _master()
|
||||
doc = _filled(project)
|
||||
rows = {row["id"]: row for row in doc["줄"]}
|
||||
rows["s300.00"]["값"]["h_intake"] = 2
|
||||
rows["s85.05#1"]["값"]["memo"] = "흄관 확인"
|
||||
doc["줄"][0]["값"]["ps"] = 3
|
||||
saved = fill.strip_design({**doc, "결과": {"계산": {}}})
|
||||
assert [c["id"] for c in saved["열"]] == [
|
||||
c["id"] for c in master["열"]
|
||||
] # 펼침 규칙 열로 되돌림
|
||||
assert "알림" not in saved and "결과" not in saved
|
||||
assert saved["줄"] == [
|
||||
{"id": "all", "값": {"sta": "전구간", "ps": 3}, "고정": "전구간"},
|
||||
{"id": "s85.05#1", "값": {"sta": "NO.4+5.05", "memo": "흄관 확인"}},
|
||||
{"id": "s300.00", "값": {"sta": "NO.15", "h_intake": 2}},
|
||||
]
|
||||
again = {row["id"]: row for row in _filled(project, saved)["줄"]}
|
||||
assert again["s300.00"]["값"]["h_intake"] == 2 and again["s300.00"]["값"]["ps"] == 1
|
||||
assert again["s85.05#1"]["값"]["memo"] == "흄관 확인"
|
||||
assert again["s85.05#1"]["값"]["pp_len|흄관|1000"] == 14.0
|
||||
|
||||
|
||||
def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path) -> None:
|
||||
saved = fill.strip_design(_filled(project))
|
||||
saved["줄"].append({"id": "s300.00", "값": {"sta": "NO.15", "h_intake": 2}})
|
||||
saved["줄"].append({"id": "s555.00", "값": {"sta": "NO.27+15", "h_intake": 1}})
|
||||
doc = _filled(project, saved)
|
||||
last = doc["줄"][-1]
|
||||
assert last["id"] == "s555.00" and last["손"] is True
|
||||
assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"}
|
||||
kept = fill.strip_design(doc)
|
||||
assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음
|
||||
|
||||
@@ -265,3 +265,33 @@ def test_옛_프로젝트_넣기_도구는_더하기만_폴더_없으면_건너
|
||||
assert not (world["storage"] / "7/42/gone").exists()
|
||||
assert migrate.migrate(projects)[0]["상태"] == "이미 있음"
|
||||
assert "| 7 | 42 | 옛 |" in migrate.render(list(report.values()), [])
|
||||
|
||||
|
||||
def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dict[str, Any]) -> None:
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
|
||||
client = world["client"]
|
||||
master = json.loads(
|
||||
(
|
||||
config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
filled = fill.fill_document(
|
||||
master,
|
||||
[
|
||||
{
|
||||
"type_id": "position_sign",
|
||||
"placement": "point",
|
||||
"chainage_m": 40.0,
|
||||
"start_m": None,
|
||||
"end_m": None,
|
||||
"options": {},
|
||||
}
|
||||
],
|
||||
)
|
||||
filled["줄"][1]["값"]["h_intake"] = 2
|
||||
version = client.get(_url()).json()["판"]
|
||||
assert client.put(_url(), json={"판": version, "문서": filled}).status_code == 200
|
||||
stored = layers.read_template(_root(world, P1) / "templates", "table", "구조물집계표")["문서"]
|
||||
assert stored["줄"][1] == {"id": "s40.00", "값": {"sta": "NO.2", "h_intake": 2}}
|
||||
assert "알림" not in stored and all("|" not in c["id"] for c in stored["열"])
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface CadLoadExtra {
|
||||
recoveryScope?: string;
|
||||
/** 보기 전용 — 확정본처럼 그리기·수정이 막힌다. */
|
||||
readOnly?: boolean;
|
||||
/** CAD 제목표시줄 부제 — 없으면 「B07 상세 설계」. */
|
||||
hostTitle?: string;
|
||||
}
|
||||
|
||||
export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
|
||||
Reference in New Issue
Block a user