From 303342a56e201742c90bb9c23f4de31e68be0d21 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:29:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(b07):=20=ED=9A=A1=EB=8B=A8=20=EC=9E=A5?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=EB=A7=88=EB=8B=A4=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=EC=B8=B5=20=C2=B7=20=EC=98=9B=20=ED=99=95=EC=A0=95=20=EC=9E=A5?= =?UTF-8?q?=20=EC=9B=90=EC=A7=80=EB=B0=98=20=EC=9E=A0=EA=B8=88=20=ED=92=82?= =?UTF-8?q?(212-1=20=C2=B7=20212-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 새로 만든 장은 서버 · CAD 걸름 모두 원지반이 잡힘 — 안 잡힌 것은 180-1 앞에 원지반 층 잠금째 저장된 확정 장 또는 반영 전 서버 · 페이지 메모리의 옛 도면 - 확정 장을 열 때 원지반 층 잠금 풂 · 측점 층 없는 확정 장은 stale_layers 안내([수정] 때 새 꼴) - 장의 측점마다 층 하나(b08-station-{측점} · 「횡단 No.3」) = 원지반 · 계획선 · 구조물 · 암 그림(자식 선까지) · 종류는 색 · 선 꼴 · 표 · 글은 수량표 층 · 종류 층 지움 - 개체 id · groupId 그대로 · placement 에 layer_id · design_ids — 화면 덧그림도 측점 층 · 서버 설계선은 id 로 걷음 - DXF 층 이름 = 측점 층 id - 새 시험 test_212 · 174 · 180 · 180_2 원지반 찾기를 색으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Api_Fetch.ts | 2 + .../B07_DesignDetail_Engine_Cad_Sheet.py | 29 ++- .../B07_DesignDetail_Router_Support.py | 12 ++ .../B07_DesignDetail_UI_Cad_Structures.ts | 15 +- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 5 + .../tester/test_174_b07_cross_cell_stack.py | 6 +- .../tester/test_180_2_b07_cross_scale.py | 6 +- resources/tester/test_180_b07_cross_group.py | 9 +- .../test_212_b07_cross_station_layers.py | 185 ++++++++++++++++++ 9 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_212_b07_cross_station_layers.py diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 696c3f773..2efd5bcc4 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -41,6 +41,8 @@ export interface CadDrawing { stale_layout?: boolean; /** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */ stale_scale?: boolean; + /** 횡단 장 — 측점 도면층(212-2) 앞에 확정된 저장본. [수정]을 누르면 측점 층으로 다시 만든다. */ + stale_layers?: boolean; layers: { id: string; name: string; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index e2b8d5eb5..1dc94c77b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -5,6 +5,8 @@ 칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침). 칸 = 인쇄되는 표 격자선 · 블록 테두리 없음 · 칸보다 큰 단면은 `overflow_cells`(169-2). 칸 안 = 표를 칸 아래 가운데에 먼저 · 그 위 글 · 그림(도로 중심 = 칸 가운데)(174-1). +측점마다 도면층 하나(「횡단 No.3」) = 원지반 · 계획선 · 구조물 · 암 그림 · 종류는 색 · 선 꼴 +· 표 · 글은 수량표 층(212-2). """ import re @@ -52,6 +54,10 @@ CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,}(?:\.\d{2})?)m?") _BORDER_PAD_MM = CROSS_BORDER_PAD_MM # 칸 아래선 ~ 표 아래선 여백(mm) — 같은 행 표가 한 높이(174-1) CELL_TABLE_PAD_MM = 5.0 +# 측점 도면층 id 머리(212-2) — 뒤에 측점 · DXF 층 이름도 이 id +STATION_LAYER_PREFIX = "b08-station-" +# 측점 층으로 옮길 그림 종류 층 — 표 · 글(수량표 층)은 그대로 +_DRAWING_LAYER_IDS = {GROUND_LAYER_ID, DESIGN_LAYER_ID, STRUCTURE_LAYER_ID, ROCK_LAYER_ID} def _design_points(design_line: list[Any] | None) -> list[tuple[float, float]]: @@ -178,11 +184,20 @@ def _grid_lines(sheet_id: str, cols: int, rows: int) -> list[dict[str, Any]]: ] +def _move_to_layer(entity: dict[str, Any], layer_id: str) -> None: + """그림 종류 층 개체(자식 선까지)를 측점 층으로 — id 는 그대로(212-2).""" + if entity.get("layerId") in _DRAWING_LAYER_IDS: + entity["layerId"] = layer_id + for child in entity.get("children") or []: + child["layerId"] = layer_id + + def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]: """한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로).""" entities: list[dict[str, Any]] = [] placements: list[dict[str, Any]] = [] overflow: list[list[float]] = [] + station_layers: list[dict[str, Any]] = [] slots = sheet.get("slots") or [] cols, rows = sheet.get("grid") or (4, 4) usable_w, usable_h = usable_area() @@ -222,8 +237,17 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> group_id = str(uuid5(_ENTITY_NS, f"{seed_id}:group")) for entity in placed["entities"]: entity["groupId"] = group_id + # 측점 도면층(212-2) — 층 관리에서 그 측점 그림만 고르기 · 켜고 끄기 · 잠그기 + layer_id = f"{STATION_LAYER_PREFIX}{section['chainage']}" + title = section.get("title") or f"{section['chainage']}" + station_layers.append(_layer(layer_id, f"횡단 {title}")) + design_ids = [e["id"] for e in placed["entities"] if e["layerId"] == DESIGN_LAYER_ID] + for entity in placed["entities"]: + _move_to_layer(entity, layer_id) for placement in placed.get("cross_placements") or []: placement["group_id"] = group_id + placement["layer_id"] = layer_id + placement["design_ids"] = design_ids # 화면이 다시 그려 갈아 끼울 서버 설계선 entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) @@ -246,10 +270,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> # 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2) "overflow_cells": overflow, "layers": [ - _layer(GROUND_LAYER_ID, "원지반"), - _layer(DESIGN_LAYER_ID, "계획선"), - _layer(STRUCTURE_LAYER_ID, "구조물"), - _layer(ROCK_LAYER_ID, "암 경계선"), + *station_layers, _layer(CROSS_TABLE_LAYER_ID, "수량 산출표"), _layer(FRAME_LAYER_ID, "도각", locked=True), ], diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index b63a43981..c70185032 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -18,6 +18,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, + GROUND_LAYER_ID, build_cross_drawing, infer_station_interval, station_no_label, @@ -32,6 +33,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_hau from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, + STATION_LAYER_PREFIX, build_cross_sheet, plan_cross_sheets, ) @@ -462,12 +464,22 @@ def _read_drawing( else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) + layers = [layer for layer in saved.get("layers") or [] if isinstance(layer, dict)] + if kind == "cross": + # 180-1 앞 확정본 = 원지반 층 잠금째 저장 — 열 때 풂(212-1) + for layer in layers: + if layer.get("id") == GROUND_LAYER_ID: + layer["isLocked"] = False if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT: saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내 elif kind == "cross" and drawing_id.startswith("cross_s"): scale = read_cross_layout(project_root)["table_scale"] if manifest_entry.get("table_scale", 1.0) != scale: saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2) + elif not any( + str(layer.get("id")).startswith(STATION_LAYER_PREFIX) for layer in layers + ): + saved["stale_layers"] = True # 측점 층 앞 확정본 — [수정] 때 측점 층(212-2) 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 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index f0e55d6ba..c4e446c65 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -34,6 +34,16 @@ export interface CrossPlacement { frame?: number[]; /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ group_id?: string; + /** 측점 도면층(212-2) — 덧그림도 그 층에. */ + layer_id?: string; + /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ + design_ids?: string[]; +} + +/** 개체(자식 선까지)를 측점 층으로(212-2). */ +function moveToLayer(entity: Entity, layerId: string): void { + entity.layerId = layerId; + for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); } /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ @@ -62,6 +72,7 @@ export async function appendStructureEntities( let added = 0; const fresh: Entity[] = []; const replaced: number[][] = []; + const designIds = new Set(); for (const placement of placements) { const section = sections.find( (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, @@ -81,6 +92,8 @@ export async function appendStructureEntities( } if (!entities.length) continue; if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; + if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); + for (const id of placement.design_ids ?? []) designIds.add(id); // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. replaced.push(frame); fresh.push(...entities); @@ -89,7 +102,7 @@ export async function appendStructureEntities( if (replaced.length) { // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. drawing.entities = drawing.entities.filter((entity) => { - if (entity.layerId !== DESIGN_LAYER_ID) return true; + if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; const box = entityBox(entity); if (!box) return true; return !replaced.some( diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 22499e06f..4ee7d0a34 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -365,6 +365,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { "옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.", "info", ); + } else if (response.drawing.stale_layers) { + showToast( + "측점 도면층 앞에 확정된 장입니다. [수정]을 누르면 측점마다 층(횡단 No.)으로 다시 만듭니다.", + "info", + ); } } catch (error) { cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); diff --git a/resources/tester/test_174_b07_cross_cell_stack.py b/resources/tester/test_174_b07_cross_cell_stack.py index cb4982436..eefcac2ce 100644 --- a/resources/tester/test_174_b07_cross_cell_stack.py +++ b/resources/tester/test_174_b07_cross_cell_stack.py @@ -11,7 +11,7 @@ import pytest -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import GROUND_COLOR from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CELL_TABLE_PAD_MM, build_cross_sheet, @@ -95,7 +95,9 @@ def test_같은_행_표_한_높이_그림_중심_칸_가운데() -> None: assert tables[0][0] == pytest.approx(tables[1][0]) # 도로 중심(offset 0) = 칸 가운데 · 그림(원지반) 아래 끝 = 글(없으면 표) 위 8 mm grounds = [ - entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + entities_bbox([e]) + for e in drawing["entities"] + if e.get("type") == "PolyLine" and e.get("lineColor") == GROUND_COLOR ] for placement, slot, table, design, ground in zip( drawing["cross_placements"], sheet["slots"], tables, designs, grounds, strict=True diff --git a/resources/tester/test_180_2_b07_cross_scale.py b/resources/tester/test_180_2_b07_cross_scale.py index 3fc8dec6e..e9d47710d 100644 --- a/resources/tester/test_180_2_b07_cross_scale.py +++ b/resources/tester/test_180_2_b07_cross_scale.py @@ -14,7 +14,7 @@ from pathlib import Path import pytest -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_COLOR from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CELL_TABLE_PAD_MM, build_cross_sheet, @@ -81,7 +81,9 @@ def test_배율_15_표_크기와_쌓기() -> None: bottom = shape["origin"]["y"] - sum(shape["rowHeights"]) assert bottom == pytest.approx(slot[1] - h / 4 + CELL_TABLE_PAD_MM) # 표 아래선 그대로 grounds = [ - entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID + entities_bbox([e]) + for e in drawing["entities"] + if e.get("type") == "PolyLine" and e.get("lineColor") == GROUND_COLOR ] # 글 2 줄 × 3.0 × 1.5 = 9 mm · 간격 8 mm 는 배율 안 탐 tops = [t["shapeData"]["origin"]["y"] for t in tables] diff --git a/resources/tester/test_180_b07_cross_group.py b/resources/tester/test_180_b07_cross_group.py index 4feffb1a1..d98296baa 100644 --- a/resources/tester/test_180_b07_cross_group.py +++ b/resources/tester/test_180_b07_cross_group.py @@ -10,6 +10,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( FRAME_LAYER_ID, + GROUND_COLOR, GROUND_LAYER_ID, build_cross_drawing, ) @@ -57,7 +58,9 @@ def _ground_locked(drawing: dict) -> bool: def test_원지반_층_안_잠김() -> None: - assert _ground_locked(_drawing()) is False + # 장 = 원지반이 측점 층에(212-2) · 도각 말고 잠긴 층 없음 + layers = _drawing()["layers"] + assert not any(layer["isLocked"] for layer in layers if layer["id"] != FRAME_LAYER_ID) single = build_cross_drawing(_source(0.0), "cross_00000m", _design_line(), None, None, "") assert _ground_locked(single) is False @@ -72,9 +75,9 @@ def test_측점마다_groupId_하나() -> None: assert {e["groupId"] for e in grouped} == set(group_ids) for group_id in group_ids: members = [e for e in grouped if e["groupId"] == group_id] - layers = {e["layerId"] for e in members} types = {e["type"] for e in members} - assert GROUND_LAYER_ID in layers and "Table" in types and "Text" in types + ground = any(e["type"] == "PolyLine" and e["lineColor"] == GROUND_COLOR for e in members) + assert ground and "Table" in types and "Text" in types # 격자선 · 도각은 묶지 않음 assert all("groupId" not in e for e in drawing["entities"] if e["layerId"] == FRAME_LAYER_ID) assert all(e["layerId"] != FRAME_LAYER_ID for e in grouped) diff --git a/resources/tester/test_212_b07_cross_station_layers.py b/resources/tester/test_212_b07_cross_station_layers.py new file mode 100644 index 000000000..baa293a31 --- /dev/null +++ b/resources/tester/test_212_b07_cross_station_layers.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""212-1 · 212-2 B07 횡단 장 — 원지반 고르기 · 측점별 도면층. + +못박는 것 + - 212-1 옛 확정 장(원지반 층 잠금째 저장)도 열 때 원지반 잠금 풂 · 측점 층 없으면 stale_layers. + - 212-2 장의 측점마다 층 하나(`b08-station-{측점}` · 이름 「횡단 {제목}」) = 원지반 · 계획선 · + 암 그림(자식 선까지) · 표 · 글은 수량표 층 · 격자선 · 도각은 도각 층 · 종류 층 없음. + - 개체 id 는 그대로(층만 바뀜) · placement 에 layer_id · design_ids(화면 덧그림 갈아 끼우기). + - DXF 층 이름 = 측점 층 id 그대로(금지 문자 없음). + - 화면 덧그림도 측점 층 · design_ids 로 서버 설계선 걷음(소스 확인 · typecheck 는 따로). +""" + +import json +from pathlib import Path +from uuid import uuid5 + +import ezdxf + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _POLY_NS, + CROSS_TABLE_LAYER_ID, + DESIGN_COLOR, + DESIGN_LAYER_ID, + DRAWING_FORMAT, + FRAME_LAYER_ID, + GROUND_COLOR, + GROUND_LAYER_ID, + ROCK_LAYER_ID, + STRUCTURE_LAYER_ID, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( + STATION_LAYER_PREFIX, + build_cross_sheet, + plan_cross_sheets, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import _layer_name, drawing_to_dxf +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates +from B07_DesignDetail.B07_DesignDetail_Router_Support import CROSS_SHEET_LAYOUT, _read_drawing + +ROOT = Path(__file__).resolve().parents[2] +KIND_LAYERS = {GROUND_LAYER_ID, DESIGN_LAYER_ID, STRUCTURE_LAYER_ID, ROCK_LAYER_ID} + + +def _source(chainage: float) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x} + for x in range(-5, 6) + ], + } + + +def _sheet() -> tuple[dict, dict]: + use_project_templates(None) + chainages = [0.0, 20.0, 40.0, 60.0] + sheet = plan_cross_sheets(chainages, {"default": [2, 2]})[0] + sections = [ + { + "chainage": c, + "source": _source(c), + # 암 지반 = 암 경계선도 측점 층으로 + "design": {"rock_boundary_offset_m": -1.0} if c == 20.0 else None, + "design_line": [{"offset_m": x, "elevation_m": 100.0} for x in (-3.0, 0.0, 3.0)], + "quantity_table": {"ground": 1.0, "planned": 0.5}, + "title": f"No.{int(c // 20)}", + } + for c in chainages + ] + return sheet, build_cross_sheet(sheet, sections) + + +def _flat(entities: list[dict]) -> list[dict]: + out = [] + for entity in entities: + out.append(entity) + out.extend(_flat(entity.get("children") or [])) + return out + + +def test_측점마다_층_하나_이름_횡단_No(): + _sheet_, drawing = _sheet() + layers = drawing["layers"] + ids = [layer["id"] for layer in layers] + assert ids[:4] == [f"{STATION_LAYER_PREFIX}{c}" for c in (0.0, 20.0, 40.0, 60.0)] + assert [layer["name"] for layer in layers[:4]] == [ + "횡단 No.0", + "횡단 No.1", + "횡단 No.2", + "횡단 No.3", + ] + assert ids[4:] == [CROSS_TABLE_LAYER_ID, FRAME_LAYER_ID] + assert not KIND_LAYERS & set(ids) # 종류 층 없음 — 빈 층이 안 남음 + assert all(layer["isVisible"] and not layer["isLocked"] for layer in layers[:4]) + + +def test_그림은_측점_층_표_글은_수량표_층_격자는_도각(): + _sheet_, drawing = _sheet() + flat = _flat(drawing["entities"]) + assert not any(e["layerId"] in KIND_LAYERS for e in flat) # 자식 선까지 + by_layer: dict[str, list[dict]] = {} + for entity in drawing["entities"]: + by_layer.setdefault(entity["layerId"], []).append(entity) + for chainage in (0.0, 20.0, 40.0, 60.0): + members = by_layer[f"{STATION_LAYER_PREFIX}{chainage}"] + colors = {e["lineColor"] for e in members} + assert GROUND_COLOR in colors and DESIGN_COLOR in colors + assert all(e["type"] == "PolyLine" for e in members) + assert len({e["groupId"] for e in members}) == 1 # 180-1 groupId 그대로 + # 암 지반 측점만 점선 암 경계선(종류 = 선 꼴) + rock = [e for e in by_layer[f"{STATION_LAYER_PREFIX}20.0"] if e.get("lineDash")] + assert len(rock) == 1 + assert not any(e.get("lineDash") for e in by_layer[f"{STATION_LAYER_PREFIX}0.0"]) + assert {e["type"] for e in by_layer[CROSS_TABLE_LAYER_ID]} <= {"Table", "Text"} + assert all("groupId" not in e for e in by_layer[FRAME_LAYER_ID]) + + +def test_id_그대로_placement_layer_id_design_ids(): + sheet, drawing = _sheet() + by_id = {e["id"]: e for e in drawing["entities"]} + for placement in drawing["cross_placements"]: + chainage = placement["chainage_m"] + layer_id = f"{STATION_LAYER_PREFIX}{chainage}" + assert placement["layer_id"] == layer_id + seed = f"{sheet['id']}:{chainage}" + # 개체 id 는 종류 층 씨앗 그대로 — 저장본 · 덧그림 짝이 안 흔들림 + ground = by_id[str(uuid5(_POLY_NS, f"{seed}:{GROUND_LAYER_ID}"))] + assert ground["layerId"] == layer_id and ground["lineColor"] == GROUND_COLOR + design_ids = placement["design_ids"] + assert design_ids == [str(uuid5(_POLY_NS, f"{seed}:{DESIGN_LAYER_ID}"))] + assert all(by_id[i]["lineColor"] == DESIGN_COLOR for i in design_ids) + + +def test_DXF_층_이름_측점_층_id(tmp_path: Path): + _sheet_, drawing = _sheet() + for layer in drawing["layers"]: + assert _layer_name(layer["id"]) == layer["id"] + data, _skipped = drawing_to_dxf(drawing) + (tmp_path / "sheet.dxf").write_bytes(data) + names = {layer.dxf.name for layer in ezdxf.readfile(tmp_path / "sheet.dxf").layers} + assert {f"{STATION_LAYER_PREFIX}{c}" for c in (0.0, 20.0, 40.0, 60.0)} <= names + assert not KIND_LAYERS & names + + +def test_옛_확정_장_원지반_잠금_풂_측점_층_안내(tmp_path: Path): + stage = tmp_path / "B07_DesignDetail" + (stage / "drawings").mkdir(parents=True) + layers = [ + {"id": GROUND_LAYER_ID, "name": "원지반", "isVisible": True, "isLocked": True}, + {"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": True}, + ] + saved = {"format": DRAWING_FORMAT, "entities": [], "layers": layers} + path = stage / "drawings" / "cross_s00000m.json" + path.write_text(json.dumps(saved), encoding="utf-8") + entry = {"kind": "cross", "label": "1장", "confirmed": True, "layout": CROSS_SHEET_LAYOUT} + (stage / "manifest.json").write_text( + json.dumps({"drawings": {"cross_s00000m": entry}}), encoding="utf-8" + ) + + def read() -> dict: + _k, _l, drawing, confirmed, _t = _read_drawing( + tmp_path, tmp_path / "x.json", "cross_s00000m" + ) + assert confirmed + return drawing + + drawing = read() + locked = {layer["id"]: layer["isLocked"] for layer in drawing["layers"]} + assert locked == {GROUND_LAYER_ID: False, FRAME_LAYER_ID: True} # 도각은 그대로 잠금 + assert drawing.get("stale_layers") is True + # 측점 층으로 확정한 장은 안내 없음 + saved["layers"] = [{"id": f"{STATION_LAYER_PREFIX}0", "name": "횡단 No.0"}, layers[1]] + path.write_text(json.dumps(saved), encoding="utf-8") + assert "stale_layers" not in read() + + +def test_화면_덧그림_측점_층_design_ids(): + struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts").read_text( + encoding="utf-8" + ) + assert "layer_id?: string;" in struct and "design_ids?: string[];" in struct + assert "moveToLayer(entity, placement.layer_id)" in struct + assert "!designIds.has(entity.id as string)" in struct + page = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8") + assert "response.drawing.stale_layers" in page From 473e3532e887b31e6a19b0e6bedb25f0775d8fee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:32:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?style(b07):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EC=B9=B8=20=EB=8B=A8=EC=B6=94=20=C2=B7?= =?UTF-8?q?=20=EC=88=AB=EC=9E=90=20=EC=B9=B8=20=ED=95=9C=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20=ED=95=9C=20=EC=A4=84=EC=97=90=20=ED=95=9C?= =?UTF-8?q?=20=EC=9D=BC=20=C2=B7=20=EA=B2=80=EC=9D=80=20=EC=84=A0=20=3D=20?= =?UTF-8?q?=EC=9D=B8=EC=87=84=20=EC=B9=B8=EC=84=A0=20=ED=8C=90=EC=A0=95(21?= =?UTF-8?q?2-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줄 = 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기] · 한 줄 안 같은 너비 - 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(--btn-bar-* · 높이 16) · 옛 한 줄 감기 · 48px 고정 지움 - 안내선 숨김 뒤 남는 검은 선 = 인쇄 칸선(도각 층 · #8ea0b5 · 밝은 바탕에서 짙게) — 지우지 않음 - 새 시험 test_212_3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_UI_Panels.ts | 22 ++++-- .../B07_DesignDetail_UI_Style.css | 37 +++++++-- .../test_212_3_b07_cross_layout_buttons.py | 79 +++++++++++++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_212_3_b07_cross_layout_buttons.py diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 0f214f7c5..68426a2fa 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -185,8 +185,9 @@ export interface CrossLayoutExtras { } /** - * 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」. - * `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. + * 횡단 장 배치 칸(140-3 · 153-1) — 줄마다 한 일(212-3): + * 「배치 열 [4] 행 [4]」 · 「[이 장] [모든 장]」 · 「표 배율 [1] [배율 적용]」 · (부르는 쪽이 붙이는 [안내선 숨기기]). + * 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*`) · `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔. */ export function buildCrossLayoutControls( grid: [number, number], @@ -196,6 +197,12 @@ export function buildCrossLayoutControls( ): HTMLElement { const box = document.createElement("div"); box.className = "b07-cross-layout"; + const row = (...items: HTMLElement[]): HTMLElement => { + const line = document.createElement("div"); + line.className = "b07-cross-layout__row"; + line.append(...items); + return line; + }; const number = (value: number, label: string) => createInputField({ label, @@ -227,10 +234,11 @@ export function buildCrossLayoutControls( return button; }; box.append( - cols.root, - rows.root, - action("이 장", "이 장만 이 배치로", onApply), - action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + row(cols.root, rows.root), + row( + action("이 장", "이 장만 이 배치로", onApply), + action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault), + ), ); if (extras.scale) { const { onApply: applyScale } = extras.scale; @@ -255,7 +263,7 @@ export function buildCrossLayoutControls( }, }); button.title = "모든 장의 표 · 표 위 글 크기(기본 1)"; - box.append(scale.root, button); + box.append(row(scale.root, button)); } return box; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index e99f64b41..71c501fb3 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -225,8 +225,9 @@ color: var(--color-text-muted); } -/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장]. - 좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */ +/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일: + 배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기]. + 단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */ .b07-cross-layout-slot { grid-column: 1 / -1; } @@ -237,25 +238,45 @@ .b07-cross-layout { display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-4) var(--spacing-8); + flex-direction: column; + gap: var(--spacing-4); } -.b07-cross-layout > .ui-field { +.b07-cross-layout__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b07-cross-layout__row > * { + flex: 1 1 0; + min-width: 0; +} + +.b07-cross-layout .ui-field { display: flex; flex-direction: row; align-items: center; gap: var(--spacing-4); } +.b07-cross-layout .ui-btn, +.b07-cross-layout .ui-field .ui-input { + height: auto; + padding: var(--btn-bar-padding); + font-size: var(--btn-bar-font-size); + line-height: 1; +} + .b07-cross-layout .ui-field__label { margin: 0; + font-size: var(--btn-bar-font-size); white-space: nowrap; } -.b07-cross-layout .ui-input { - width: 48px; +.b07-cross-layout .ui-field .ui-input { + width: 100%; + min-width: 0; } .b07-cross-layout .ui-field__error { diff --git a/resources/tester/test_212_3_b07_cross_layout_buttons.py b/resources/tester/test_212_3_b07_cross_layout_buttons.py new file mode 100644 index 000000000..3b51c0f18 --- /dev/null +++ b/resources/tester/test_212_3_b07_cross_layout_buttons.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +"""212-3 B07 좌측 「횡단도」 배치 칸 — 단추 · 숫자 칸 한 규격 · 한 줄에 한 일 · 남는 검은 선 판정. + +못박는 것 + - 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」 + · [안내선 숨기기](부르는 쪽). + - 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비. + - 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음. + - [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5` + · 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔. +""" + +import re +from pathlib import Path + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID, TABLE_LINE_COLOR +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import _grid_lines +from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates + +ROOT = Path(__file__).resolve().parents[2] +CSS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Style.css").read_text(encoding="utf-8") +PANELS = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Panels.ts").read_text(encoding="utf-8") +PAGE = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text(encoding="utf-8") +GUIDE = (ROOT / "B07_DesignDetail/openwebcad/src/integration/guide-area.ts").read_text( + encoding="utf-8" +) +THEME = (ROOT / "ui_template/ui_template_theme.css").read_text(encoding="utf-8") + + +def _block(selector: str) -> str: + m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + assert m, selector + return m.group(1) + + +def _decl(block: str, prop: str) -> str: + m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block) + assert m, prop + return m.group(1).strip() + + +def test_단추_숫자_칸_한_규격_토큰(): + shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input") + assert _decl(shared, "padding") == "var(--btn-bar-padding)" + assert _decl(shared, "font-size") == "var(--btn-bar-font-size)" + assert _decl(shared, "line-height") == "1" + assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤 + label = _block(".b07-cross-layout .ui-field__label") + assert _decl(label, "font-size") == "var(--btn-bar-font-size)" + # 토큰은 theme 한 곳(187) + assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME + assert "--btn-bar-font-size: var(--text-caption);" in THEME + + +def test_한_줄에_한_일_같은_너비(): + box = _block(".b07-cross-layout") + assert _decl(box, "flex-direction") == "column" + assert "flex-wrap" not in box + assert _decl(_block(".b07-cross-layout__row > *"), "flex") == "1 1 0" + assert "width: 48px" not in CSS + assert 'line.className = "b07-cross-layout__row"' in PANELS + assert "row(cols.root, rows.root)" in PANELS + assert re.search(r"row\(\s*action\(\"이 장\"[^;]*action\(\"모든 장\"", PANELS, re.S) + assert "box.append(row(scale.root, button));" in PANELS + # 안내선 단추 = 배치 칸 맨 아래 한 줄(전체 폭) + assert "controls.append(guideToggle);" in PAGE + + +def test_남는_검은_선은_인쇄_칸선_지우지_않음(): + use_project_templates(None) + lines = _grid_lines("cross_s00000m", 4, 4) + assert len(lines) == 10 # 세로 5 · 가로 5 그대로 + assert all(e["layerId"] == FRAME_LAYER_ID for e in lines) + assert all(e["lineColor"] == TABLE_LINE_COLOR == "#8ea0b5" for e in lines) + # 안내선 단추는 겹판(점선 영역 + 붉은 틀)만 — 도면 개체를 안 건드림 + draw = GUIDE[GUIDE.index("export function drawGuide") :] + draw = draw[: draw.index("\n}\n")] + assert "if (!guide || !guideVisible) return;" in draw + assert "getEntities" not in draw and "setEntities" not in draw From c06661093cbbaeea63cb1be7ae09cbd4aa85e36d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:32:14 +0900 Subject: [PATCH 3/4] =?UTF-8?q?style(B06):=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=B9=A9=20=EC=A4=84=20=EC=9C=84=20=EC=97=AC=EB=B0=B1=2016?= =?UTF-8?q?=E2=86=928=20=C2=B7=20=EC=B9=A9=20=EC=A4=84=20=E2=86=94=20?= =?UTF-8?q?=EC=B2=AB=20=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=2028=E2=86=9216=20px(217-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B06_Section/B06_Section_UI_Style_Cross.css | 7 +++-- .../tester/test_217_1_b06_notice_spacing.py | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_217_1_b06_notice_spacing.py diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 494c58df0..6bd6c56e3 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -124,7 +124,8 @@ } /* 알림 줄(138-3) — 카드 칸 맨 위 고정 · 알림 없어도 줄 유지 · 오른쪽 끝 열 수 칸. - 위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게 */ + 위 여백(카드 칸 padding)만큼 끌어올려 그 틈으로 카드가 비치지 않게. + 217-1 — 칩 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16(카드 칸 gap 24 에서 8 을 당김) */ .b06-section__notice-bar { position: sticky; top: calc(-1 * var(--spacing-16)); @@ -132,8 +133,8 @@ display: flex; align-items: flex-start; gap: var(--spacing-8); - margin-top: calc(-1 * var(--spacing-16)); - padding-block: var(--spacing-16) var(--spacing-4); + margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8)); + padding-block: var(--spacing-8) 0; background: var(--color-bg); } diff --git a/resources/tester/test_217_1_b06_notice_spacing.py b/resources/tester/test_217_1_b06_notice_spacing.py new file mode 100644 index 000000000..e15d8ef74 --- /dev/null +++ b/resources/tester/test_217_1_b06_notice_spacing.py @@ -0,0 +1,30 @@ +"""217-1 B06 알림 칩 줄 여백 줄임 — 줄 위 여백 16→8 · 칩 줄 ↔ 첫 카드 28→16 (CSS 값 고정).""" + +from test_196_1_b06_notice_chips import B06 + +CSS = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8") + + +def _rule(selector: str) -> str: + body = CSS[CSS.index(selector + " {") :] + return body[: body.index("}")] + + +def test_칩_줄_위_여백은_8_아래_여백은_0(): + bar = _rule(".b06-section__notice-bar") + assert "padding-block: var(--spacing-8) 0;" in bar + assert "spacing-16) var(--spacing-4)" not in bar + + +def test_첫_카드와의_간격은_카드_칸_gap_24에서_8을_당겨_16(): + bar = _rule(".b06-section__notice-bar") + # 위 = 카드 칸 padding(16) 상쇄 · 아래 = gap(24) 중 8 당김 + assert "margin-block: calc(-1 * var(--spacing-16)) calc(-1 * var(--spacing-8));" in bar + assert "gap: var(--spacing-24)" in _rule(".b06-section__cards") + + +def test_sticky_top_과_scroll_margin_그대로(): + bar = _rule(".b06-section__notice-bar") + assert "position: sticky" in bar + assert "top: calc(-1 * var(--spacing-16))" in bar + assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS From f8fff70d56b8e8fd86b42150f229abb87bf908a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:32:58 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(=EB=8B=A8=EC=B6=95=ED=82=A4):=20?= =?UTF-8?q?=EC=A0=84=EC=97=AD=20Ctrl=20=EB=8B=A8=EC=B6=95=ED=82=A4=20?= =?UTF-8?q?=ED=95=9C=EA=B8=80=20=EC=9E=90=ED=8C=90=20=EB=8C=80=EC=9D=91=20?= =?UTF-8?q?=C2=B7=20=EA=B3=B5=EC=9A=A9=20=ED=8C=90=EC=A0=95=20=ED=95=A8?= =?UTF-8?q?=EC=88=98(214-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A00_Common/key_shortcut.ts 새로 — 영문이면 글자 · 아니면 자판 자리(code) · CAD 와 페이지가 같이 씀 - CAD input-shortcut.ts 는 공용을 다시 내보냄 · tsconfig.app.json 에 공용 파일 포함 - B05 종단 Ctrl+Z · 노선 편집 Ctrl+Z · Y · M02 흐름도 Ctrl+Z · 카드 Ctrl+S · 나무 Ctrl+C · V · 시트 Ctrl+글자 - 시트 키는 한글 조합 중에도 Ctrl+글자 판정 - 새 시험 ctrl-shortcut-hangul · ctrl-shortcut-sheet Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/key_shortcut.ts | 49 +++++++++++++++ A00_Common/spreadsheet/spreadsheet_keys.ts | 6 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 3 +- .../B05_Profile_UI_RouteEdit_History.ts | 4 +- .../src/inputController/input-shortcut.ts | 30 +++------- B07_DesignDetail/openwebcad/tsconfig.app.json | 2 +- M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 5 +- .../M02_MasterTemplete_CardShell.ts | 3 +- .../M02_MasterTemplete_TreePanel.ts | 7 ++- .../tester/cad/ctrl-shortcut-hangul.test.ts | 40 ++++++++++++- .../tester/cad/ctrl-shortcut-sheet.test.ts | 60 +++++++++++++++++++ resources/tester/cad/vitest.ui.config.ts | 8 +++ 12 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 A00_Common/key_shortcut.ts create mode 100644 resources/tester/cad/ctrl-shortcut-sheet.test.ts create mode 100644 resources/tester/cad/vitest.ui.config.ts diff --git a/A00_Common/key_shortcut.ts b/A00_Common/key_shortcut.ts new file mode 100644 index 000000000..3957f90f7 --- /dev/null +++ b/A00_Common/key_shortcut.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2) + * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 · + * 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤. + * 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄). + * 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts` + * ========================================================================== */ + +type KeyLike = Pick & Partial>; + +/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */ +export function keyLetter(evt: KeyLike): string { + const key = evt.key ?? ""; + if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase(); + const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? ""); + return m ? (m[1] ?? m[2]).toLowerCase() : ""; +} + +/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */ +export function isCtrlKey( + evt: KeyLike & Pick, + letter: string, +): boolean { + return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter; +} + +/** Ctrl 단축키 이름 */ +export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll"; + +/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */ +export function ctrlShortcut( + evt: KeyLike & + Pick & + Partial>, +): CtrlShortcut | null { + if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null; + switch (keyLetter(evt)) { + case "z": + return evt.shiftKey ? "redo" : "undo"; + case "y": + return evt.shiftKey ? null : "redo"; + case "s": + return evt.shiftKey ? null : "save"; + case "a": + return evt.shiftKey ? null : "selectAll"; + default: + return null; + } +} diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index 829e128f6..8c6d1fb38 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -10,6 +10,7 @@ * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. * ========================================================================== */ +import { keyLetter } from "../key_shortcut"; import { cycleAbs } from "./spreadsheet_editor"; import { fillCells } from "./spreadsheet_fill"; import { @@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); return true; } - const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key); if (ctrl && !e.altKey) { switch (k) { case "z": @@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } function onKey(e: KeyboardEvent): void { - if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫 + // 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2) + if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return; const inBar = e.target === ed.formula; const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e); if (done) { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index d50323666..043b0094a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -9,6 +9,7 @@ * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. * ========================================================================== */ +import { isCtrlKey } from "../A00_Common/key_shortcut"; import type { AlignmentBase, AlignmentEdits, @@ -283,7 +284,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; - if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { + if (isCtrlKey(event, "z")) { event.preventDefault(); flushHold(); if (event.shiftKey) history.redo(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index fda74f078..d55eb6e18 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -10,6 +10,8 @@ * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. * ========================================================================== */ +import { keyLetter } from "../A00_Common/key_shortcut"; + export type Vertex = [number, number]; /** 되돌릴 수 있는 편집 상태 한 벌. */ @@ -154,7 +156,7 @@ export function bindHistoryControls(params: HistoryControlsParams): { const onKey = (event: KeyboardEvent): void => { if (!(event.ctrlKey || event.metaKey)) return; - const key = event.key.toLowerCase(); + const key = keyLetter(event); if (key === "z" && !event.shiftKey) step("undo"); else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); else return; diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts index c5efd82a0..aad585a8c 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts @@ -1,24 +1,10 @@ -/** Ctrl 단축키 이름 */ -export type CtrlShortcut = 'undo' | 'redo' | 'save' | 'selectAll'; - /** - * Ctrl 단축키 판정 — 글자(`key`)가 아니라 자판 자리(`code`). - * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 오고, Shift 를 누르면 `key` 가 대문자(「Z」)라 글자로는 안 맞는다. + * Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄. + * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다. */ -export function ctrlShortcut( - evt: Pick -): CtrlShortcut | null { - if (!evt.ctrlKey || evt.altKey) return null; - switch (evt.code) { - case 'KeyZ': - return evt.shiftKey ? 'redo' : 'undo'; - case 'KeyY': - return evt.shiftKey ? null : 'redo'; - case 'KeyS': - return evt.shiftKey ? null : 'save'; - case 'KeyA': - return evt.shiftKey ? null : 'selectAll'; - default: - return null; - } -} +export { + ctrlShortcut, + isCtrlKey, + keyLetter, + type CtrlShortcut, +} from '../../../../A00_Common/key_shortcut'; diff --git a/B07_DesignDetail/openwebcad/tsconfig.app.json b/B07_DesignDetail/openwebcad/tsconfig.app.json index 26a85a544..5ce3e3e8e 100644 --- a/B07_DesignDetail/openwebcad/tsconfig.app.json +++ b/B07_DesignDetail/openwebcad/tsconfig.app.json @@ -25,6 +25,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "../../A00_Common/key_shortcut.ts"], "exclude": ["node_modules"] } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index 6462690e5..ec3901ac3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -8,6 +8,7 @@ * 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움). * ========================================================================== */ +import { isCtrlKey } from "../../A00_Common/key_shortcut"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; @@ -209,7 +210,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] }); root.addEventListener("keydown", (ev) => { - if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if (isCtrlKey(ev, "z") && !ev.shiftKey) { if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; ev.preventDefault(); paper.undo(); @@ -258,7 +259,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane }); full.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && !ev.defaultPrevented) shrink(); - if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if (isCtrlKey(ev, "z") && !ev.shiftKey) { if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; ev.preventDefault(); paper.undo(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts b/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts index e87486094..ea6c0e4b3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts @@ -8,6 +8,7 @@ * 접기는 부모의 attachCollapsible(제목 줄 클릭) — 오른쪽 단추를 눌러도 접히지 않음. * ========================================================================== */ +import { isCtrlKey } from "../A00_Common/key_shortcut"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; @@ -76,7 +77,7 @@ export function cardShell(opt: CardShellOptions): CardShell { const host = opt.stateHost ?? opt.body; let save: (() => void) | null = null; root.addEventListener("keydown", (event) => { - if (!save || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return; + if (!save || !isCtrlKey(event, "s")) return; event.preventDefault(); save(); }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 942642abd..38b78fe5f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import "./M02_MasterTemplete_TreePanel.css"; +import { isCtrlKey } from "../A00_Common/key_shortcut"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; @@ -598,7 +599,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { body.addEventListener("keydown", (event) => { const row = (event.target as HTMLElement).closest("[data-node]"); - if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + if (!row && isCtrlKey(event, "v")) { event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기 return void paste([]); } @@ -626,10 +627,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const many = picked.size > 1 && picked.has(row.dataset.node ?? ""); if (many && api.many) void removeMany(pickedTargets()); else if (!(target.kind === "folder" && !target.path.length)) void remove(target); - } else if (target && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") { + } else if (target && isCtrlKey(event, "c")) { event.preventDefault(); copy(picked.has(row.dataset.node ?? "") ? pickedTargets() : [target]); - } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + } else if (isCtrlKey(event, "v")) { event.preventDefault(); // 붙일 자리 = 초점 줄이 폴더면 그 안 · 아니면 맨 위 void paste(target?.kind === "folder" ? target.path : []); } diff --git a/resources/tester/cad/ctrl-shortcut-hangul.test.ts b/resources/tester/cad/ctrl-shortcut-hangul.test.ts index a93f4f661..d7ed6b090 100644 --- a/resources/tester/cad/ctrl-shortcut-hangul.test.ts +++ b/resources/tester/cad/ctrl-shortcut-hangul.test.ts @@ -1,6 +1,6 @@ /** - * Ctrl 단축키 — 한글 자판에서도 되돌리기 · 다시 하기가 먹는다(PLAN 214-1). - * 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 자판 자리(code)로 판정한다. + * Ctrl 단축키 — 한글 자판에서도 먹는다(PLAN 214-1 CAD · 214-2 페이지 공용 `A00_Common/key_shortcut.ts`). + * 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 영문이 아니면 자판 자리(code)로 판정한다. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad ctrl-shortcut-hangul */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -111,3 +111,39 @@ describe("키 입력 — 한글 자판", () => { expect(prevented).not.toHaveBeenCalled(); }); }); + +// ---- 공용 판정 · 페이지(214-2) — 같은 함수를 B05 · M02 · 시트 키가 씀 ---- +import { isCtrlKey, keyLetter } from "../../../A00_Common/key_shortcut"; + +describe("keyLetter · isCtrlKey — 페이지 공용", () => { + it("한글 자판 key 「ㅋ」 + KeyZ = z · 영문 · 대문자도 소문자 · 숫자", () => { + expect(keyLetter({ key: "ㅋ", code: "KeyZ" })).toBe("z"); + expect(keyLetter({ key: "ㅊ", code: "KeyC" })).toBe("c"); + expect(keyLetter({ key: "ㅍ", code: "KeyV" })).toBe("v"); + expect(keyLetter({ key: "ㄴ", code: "KeyS" })).toBe("s"); + expect(keyLetter({ key: "Z", code: "KeyZ" })).toBe("z"); + expect(keyLetter({ key: "1", code: "Digit1" })).toBe("1"); + expect(keyLetter({ key: "Process", code: "Digit1" })).toBe("1"); + }); + it("다른 자판 배열(key 가 영문)은 key 를 따름 · 글자 아닌 키는 빈 글", () => { + expect(keyLetter({ key: "z", code: "KeyY" })).toBe("z"); // 독일식 자리 바뀜 + expect(keyLetter({ key: "Enter", code: "Enter" })).toBe(""); + expect(keyLetter({ key: "Escape", code: "Escape" })).toBe(""); + }); + it("code 없는 합성 사건은 key 로", () => { + expect(keyLetter({ key: "Z", code: "" })).toBe("z"); + }); + it("isCtrlKey — Ctrl · ⌘ 만 · 글자 맞아야", () => { + const e = (extra: object) => ({ + key: "ㅋ", + code: "KeyZ", + ctrlKey: false, + metaKey: false, + ...extra, + }); + expect(isCtrlKey(e({ ctrlKey: true }), "z")).toBe(true); + expect(isCtrlKey(e({ metaKey: true }), "z")).toBe(true); + expect(isCtrlKey(e({}), "z")).toBe(false); + expect(isCtrlKey(e({ ctrlKey: true }), "y")).toBe(false); + }); +}); diff --git a/resources/tester/cad/ctrl-shortcut-sheet.test.ts b/resources/tester/cad/ctrl-shortcut-sheet.test.ts new file mode 100644 index 000000000..0ae376b44 --- /dev/null +++ b/resources/tester/cad/ctrl-shortcut-sheet.test.ts @@ -0,0 +1,60 @@ +/** + * 시트 키 — 한글 자판에서도 Ctrl 단축키(PLAN 214-2). + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config ../../resources/tester/cad/vitest.ui.config.ts + */ +import { describe, expect, it } from "vitest"; +import { attachKeys } from "../../../A00_Common/spreadsheet/spreadsheet_keys"; + +describe("시트 키 — 한글 자판 Ctrl 단축키", () => { + const setup = () => { + const log: string[] = []; + const handlers: ((e: KeyboardEvent) => void)[] = []; + const ed = { + ta: { + addEventListener: (_: string, fn: (e: KeyboardEvent) => void) => handlers.push(fn), + removeEventListener: () => {}, + }, + formula: { addEventListener: () => {}, removeEventListener: () => {} }, + editing: () => false, + }; + const ctx = { + undo: () => log.push("undo"), + redo: () => log.push("redo"), + readOnly: false, + sheet: () => ({}), + selection: {}, + }; + attachKeys(ctx as never, ed as never, { + find: (replace: boolean) => log.push(replace ? "replace" : "find"), + }); + const press = (extra: object) => + handlers[0]({ + key: "", + code: "", + keyCode: 0, + isComposing: false, + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + target: null, + preventDefault: () => {}, + stopPropagation: () => {}, + ...extra, + } as unknown as KeyboardEvent); + return { log, press }; + }; + it("「ㅋ」 = 되돌리기 · 「ㅛ」 = 다시 하기 · Shift+「ㅋ」 = 다시 하기", () => { + const { log, press } = setup(); + press({ key: "ㅋ", code: "KeyZ" }); + press({ key: "ㅛ", code: "KeyY" }); + press({ key: "ㅋ", code: "KeyZ", shiftKey: true }); + expect(log).toEqual(["undo", "redo", "redo"]); + }); + it("「ㄹ」(KeyF) = 찾기 · 「ㅗ」(KeyH) = 바꾸기 · 조합 중에도", () => { + const { log, press } = setup(); + press({ key: "ㄹ", code: "KeyF" }); + press({ key: "Process", code: "KeyH", keyCode: 229, isComposing: true }); + expect(log).toEqual(["find", "replace"]); + }); +}); diff --git a/resources/tester/cad/vitest.ui.config.ts b/resources/tester/cad/vitest.ui.config.ts new file mode 100644 index 000000000..11ce23b73 --- /dev/null +++ b/resources/tester/cad/vitest.ui.config.ts @@ -0,0 +1,8 @@ +/** 페이지 코드(`@ui` 별칭을 쓰는 것)를 시험하는 vitest 설정 — 시험은 `ctrl-shortcut-sheet.test.ts`. */ +const root = new URL("../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"); + +export default { + root, + resolve: { alias: { "@ui": `${root}ui_template` } }, + test: { include: ["resources/tester/cad/ctrl-shortcut-sheet.test.ts"] }, +};