From 3e5b7cf21f0d4b8e60dc2ec142317132234e5025 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:35:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(b07):=20=EB=8F=84=EB=A9=B4=20=EC=9E=91?= =?UTF-8?q?=EB=8F=84=20=EC=98=81=EC=97=AD=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EC=88=AB=EC=9E=90=20=EC=B9=B8=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=20=EB=B6=80=ED=92=88=20=C2=B7=20=EB=81=8C=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8F=84=EA=B0=81=20=ED=8E=B8=EC=A7=91=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EC=A0=80=EC=9E=A5=20(169-1=C2=B7169-3=C2=B7169-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그림칸 = 양식 drawing_area 그대로(여백 2 % 없앰) · 도각 편집이 영역 싣고 [완료] 때 저장 - 도면 응답 guide(작도 영역 도면 좌표) · M02 구조물 도각 응답 area - CAD 그리기 겹판 점선 사각형(저장 · DXF · 인쇄 제외) · 모서리 · 변 끌기(도각 편집 · M02 양식만) - 숫자 칸 공용 부품(왼쪽 · 위 · 폭 · 높이 mm) — M02 양식 · B07 도각 편집 · 끌기와 한 벌 - B08 구조물도 · M02 도면 카드 = 얹은 도각 영역 가이드(보기만) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Api_Fetch.ts | 3 + .../B07_DesignDetail_Engine_Template.py | 71 +++++-- B07_DesignDetail/B07_DesignDetail_Router.py | 8 + .../B07_DesignDetail_Router_Frame.py | 4 +- B07_DesignDetail/B07_DesignDetail_Schema.py | 2 + B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 14 +- .../openwebcad/src/helpers/draw.ts | 2 + .../src/integration/aislo-drawing-bridge.ts | 10 + .../src/integration/guide-area.test.ts | 43 +++++ .../openwebcad/src/integration/guide-area.ts | 178 ++++++++++++++++++ .../M02_MasterTemplete_Drawing.ts | 77 ++++---- .../M02_MasterTemplete_Router_Drawing.py | 3 + resources/tester/test_169_area_fields.py | 129 +++++++++++++ resources/tester/test_cad_host_tabs.py | 1 + resources/tester/test_m02_drawing_area.py | 2 +- ui_template/cad_host/cad_host.css | 7 + ui_template/cad_host/cad_host.ts | 25 +++ ui_template/cad_host/cad_host_area.ts | 77 ++++++++ ui_template/cad_host/cad_host_frame_edit.ts | 38 +++- 19 files changed, 624 insertions(+), 70 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/integration/guide-area.ts create mode 100644 resources/tester/test_169_area_fields.py create mode 100644 ui_template/cad_host/cad_host_area.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index 4553de7c9..4cec19747 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -1,6 +1,7 @@ /* B07 상세 설계 도면 목록·단건 API 클라이언트. */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import type { CadGuide } from "@ui/cad_host/cad_host"; export interface DesignDrawingItem { id: string; @@ -113,6 +114,8 @@ export interface DesignDrawingResponse { confirmed: boolean; quantity_table?: QuantityTable | null; design?: CrossDesignInfo | null; + /** CAD 가이드(169-1) — 작도 영역(도면 좌표) · 넘친 칸 */ + guide?: CadGuide | null; } export interface DesignDrawingConfirmResponse { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 447f5037f..3b290e37b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -51,9 +51,6 @@ A1_TEMPLATE = "00_template_A1" COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표 # A1 내부 작도 영역(템플릿 좌표) — 양식에 `drawing_area` 칸이 없을 때만 쓴다. _A1_INNER = (42.0, 47.0, 812.0, 567.0) -# 내부 작도 영역 대비 콘텐츠 여백 비율(각 방향). 5%는 도각 안쪽에 70mm 가까이를 -# 비워 횡단 장이 한 장 더 늘었다 — 2%로 줄여 작도 영역을 쓴다(2026-08-30 사용자). -_CONTENT_MARGIN = 0.02 # 읽는 차례 = 프로젝트 작업본(`{프로젝트}/templates/drawing/`) → 없으면 시스템 양식 # (`resources/master_template/drawing/`, 읽기 전용). 설계자가 고친 도각은 작업본에 쌓인다. @@ -123,17 +120,47 @@ def drawing_area(name: str = A1_TEMPLATE) -> tuple[float, float, float, float]: 양식 칸 `drawing_area` 를 읽는다. 없거나 어긋나면 A1 기본값(`_A1_INNER`). """ area = (_load_template(name) or {}).get("drawing_area") - if ( - isinstance(area, list) - and len(area) == 4 - and all(isinstance(value, (int, float)) for value in area) - and area[0] < area[2] - and area[1] < area[3] - ): + if _valid_area(area): return (float(area[0]), float(area[1]), float(area[2]), float(area[3])) return _A1_INNER +def _valid_area(area: Any) -> bool: + """[x0, y0, x1, y1] 숫자 넷 · 왼쪽 < 오른쪽 · 아래 < 위.""" + return ( + isinstance(area, list) + and len(area) == 4 + and all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in area) + and area[0] < area[2] + and area[1] < area[3] + ) + + +def frame_guide_area( + entities: list[dict[str, Any]], layer_id: str = FRAME_LAYER_ID, name: str = A1_TEMPLATE +) -> list[float] | None: + """도면에 얹힌 도각(`layer_id` 층)의 작도 영역을 **도면 좌표**로 — CAD 가이드 선 자리(169-1). + + 도각은 양식을 같은 비율로 키우고 옮긴 것이라, 양식 bbox ↔ 도면 도각 bbox 로 영역을 옮긴다. + 도각이 없거나 양식이 비면 None. + """ + placed = entities_bbox([e for e in entities if e.get("layerId") == layer_id]) + source = entities_bbox(template_entities(name)) + if placed is None or source is None or source[2] <= source[0] or source[3] <= source[1]: + return None + scale = (placed[2] - placed[0]) / (source[2] - source[0]) + # 가로 · 세로 배율이 다르면 도각이 아닌 것(블록 테두리 등)이 섞임 — 틀린 선보다 없는 편 + if abs((placed[3] - placed[1]) - (source[3] - source[1]) * scale) > 0.5: + return None + x0, y0, x1, y1 = drawing_area(name) + return [ + round(placed[0] + (x0 - source[0]) * scale, 3), + round(placed[1] + (y0 - source[1]) * scale, 3), + round(placed[0] + (x1 - source[0]) * scale, 3), + round(placed[1] + (y1 - source[1]) * scale, 3), + ] + + def template_entities(name: str = A1_TEMPLATE) -> list[dict[str, Any]]: """도각 원본 엔티티(실치수 1:1). 편집 화면이 그대로 싣고, 저장도 이 좌표계로 받는다.""" template = _load_template(name) @@ -144,8 +171,9 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]: """도각 편집 화면이 그대로 싣는 도면 — 실치수 1:1, 잠금 없는 도각 층 하나. 1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다. + 작도 영역(`drawing_area`)도 함께 — 편집 화면이 가이드 선 · 숫자 칸으로 보인다(169-1). """ - return frame_document(template_entities(name)) + return {**frame_document(template_entities(name)), "drawing_area": list(drawing_area(name))} def frame_document(entities: list[dict[str, Any]]) -> dict[str, Any]: @@ -210,12 +238,18 @@ def reset_project_template(project_root: Path, name: str = A1_TEMPLATE) -> bool: def save_project_template( - project_root: Path, entities: list[dict[str, Any]], name: str = A1_TEMPLATE + project_root: Path, + entities: list[dict[str, Any]], + name: str = A1_TEMPLATE, + area: Any = None, ) -> Path: - """편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 건드리지 않는다.""" + """편집한 도각을 프로젝트 작업본으로 저장한다. 시스템 양식 · `_initial/` 은 건드리지 않는다. + + `area` = 편집 화면의 작도 영역(169-1) — 어긋나거나 없으면 고치기 전 양식의 값을 이어 받는다. + """ validate_template_entities(entities) - # 작도 영역은 CAD 가 모르는 칸이라 편집본에 없다 — 고치기 전 양식의 값을 이어 받는다. - area = (_load_template_at(project_root, name) or {}).get("drawing_area") + if not _valid_area(area): + area = (_load_template_at(project_root, name) or {}).get("drawing_area") document = { "format": DRAWING_FORMAT, "source": "B07 도각 편집 화면", @@ -351,12 +385,9 @@ def usable_bbox() -> tuple[float, float, float, float]: def usable_area(name: str = A1_TEMPLATE) -> tuple[float, float]: - """양식 작도 영역에서 여백을 뺀 유효 크기(mm). 척도 고정 도면의 수용 한도.""" + """양식 작도 영역 크기(mm) — 여백 없이 정한 영역 그대로(169-1). 척도 고정 도면의 수용 한도.""" ix0, iy0, ix1, iy1 = drawing_area(name) - return ( - (ix1 - ix0) * (1.0 - 2.0 * _CONTENT_MARGIN), - (iy1 - iy0) * (1.0 - 2.0 * _CONTENT_MARGIN), - ) + return (ix1 - ix0, iy1 - iy0) _PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}") diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 523906bab..36bc1b6f5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -34,6 +34,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( extract_quantity_table, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + frame_guide_area, use_project_templates, use_title_fields, ) @@ -137,6 +138,12 @@ def _asset_data_url(relative_path: str | None) -> str: return f"data:{mime};base64,{b64encode(blob).decode('ascii')}" +def _drawing_guide(drawing: dict[str, Any]) -> dict[str, Any] | None: + """CAD 가이드(169-1 · 169-2) — 작도 영역(도면 좌표) + 넘친 칸. 도각이 없으면 None.""" + area = frame_guide_area(drawing.get("entities") or []) + return {"area": area, "marks": drawing.get("overflow_cells") or []} if area else None + + async def title_block_fields(project_id: UUID) -> dict[str, str]: """도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다. @@ -362,6 +369,7 @@ async def get_design_drawing( confirmed=confirmed, quantity_table=quantity_table, design=design, + guide=_drawing_guide(drawing), ) except ValueError as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py index 2633269bc..9e7c99593 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py @@ -233,7 +233,9 @@ async def put_frame_template( if not isinstance(entities, list): raise ValueError("도각 엔티티가 없습니다.") project_root = await _project_root(project_id) - await asyncio.to_thread(save_project_template, project_root, entities) + # 작도 영역(169-1) — 편집 화면 숫자 칸 · 끌기 값 · 없으면 옛 값을 이어 받음 + area = request.drawing.get("drawing_area") + await asyncio.to_thread(save_project_template, project_root, entities, area=area) return FrameTemplateSaveResponse(project_id=str(project_id)) except (FileNotFoundError, ValueError) as exc: return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 84d73179f..265cf5f6d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -69,6 +69,8 @@ class DesignDrawingResponse(BaseModel): confirmed: bool = False # 횡단도 편집용 수량 산출표 값 (미산정 항목은 null). 종단도는 None. quantity_table: dict[str, float | None] | None = None + # CAD 가이드 선(169-1) — {area: [x0, y0, x1, y1] 도면 좌표, marks: 넘친 칸[]} · 저장 · 인쇄 안 됨 + guide: dict[str, Any] | None = None # B06에서 지정한 잠정 설계(지반유형·단면유형·절성토 단면적). 횡단도만, 없으면 None. design: dict[str, Any] | None = None diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 003fd85d3..2b2fb1fd4 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -122,6 +122,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }, onNavigate: (direction) => navigateDrawing(direction), onExport: (fileFormat) => void exportDrawingFile(fileFormat), + onGuideChange: (area) => frameEditor.guideChanged(area), }); let currentDrawing: DesignDrawingItem | undefined; @@ -330,7 +331,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만 applyConfirmButtonState(); updateInfoPanel(drawing, response); - cad.load(stash ?? response.drawing, buildMeta(drawing, response, index)); + // 가이드 = 작도 영역 점선 · 넘친 칸 붉은 틀(169-1 · 169-2 · 보기만) + const guide = response.guide ?? null; + const meta = buildMeta(drawing, response, index); + cad.load(stash ?? response.drawing, meta, false, {}, { guide }); + if (guide?.marks?.length) { + showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning"); + } if (response.drawing.stale_layout) { showToast( "옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.", @@ -496,8 +503,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { importFile: (file) => importFrameTemplate(projectId as string, file), reset: () => resetFrameTemplate(projectId as string), }, - sendLoad: (drawing, meta, frameEdit, frameFields) => - cad.load(drawing, meta, frameEdit, frameFields), + sendLoad: (drawing, meta, frameEdit, frameFields, extra) => + cad.load(drawing, meta, frameEdit, frameFields, extra), + setGuideArea: (area) => cad.setGuideArea(area), requestCadDrawing: async () => (await requestCadDrawing()).drawing, restoreDrawing: () => { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); diff --git a/B07_DesignDetail/openwebcad/src/helpers/draw.ts b/B07_DesignDetail/openwebcad/src/helpers/draw.ts index 51649bdb5..f82220b3e 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/draw.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/draw.ts @@ -28,6 +28,7 @@ import { import { getClosestSnapPoint } from './get-closest-snap-point'; import { isPointEqual } from './is-point-equal'; import { drawScene } from './scene-cache'; +import { drawGuide } from '../integration/guide-area'; /** * Hover highlight is excluded from the static scene cache (it changes every @@ -51,6 +52,7 @@ export function draw(drawController: ScreenCanvasDrawController) { drawHighlightedEntities(drawController); if (isOriginMarker()) drawOriginMarker(drawController); + drawGuide(drawController); // 작도 영역 점선(169-1) — 겹판이라 저장 · 인쇄 안 됨 drawGrips(drawController, getSelectedEntities()); drawHelpers(drawController, getAngleGuideEntities()); drawEntities(drawController, getGhostHelperEntities()); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 3960d5b73..961021c98 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -35,6 +35,7 @@ import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; import { handleTableMessage } from './aislo-table-bridge.ts'; +import { registerGuideDrag, setGuide, setGuideArea } from './guide-area.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -46,6 +47,9 @@ export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response'; export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate'; export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file'; export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save'; +/** 작도 영역 가이드(169-1 · 169-3) — 부모 숫자 칸 → CAD 갈기 · CAD 끌기 → 부모 숫자 칸 */ +export const AISLO_GUIDE_AREA_MESSAGE = 'aislo:b08:guide-area'; +export const AISLO_GUIDE_CHANGED_MESSAGE = 'aislo:b08:guide-changed'; interface DrawingLoadMessage { type: typeof AISLO_DRAWING_LOAD_MESSAGE; @@ -72,6 +76,8 @@ interface DrawingLoadMessage { /** 캔버스만 — 제목줄 · 리본 · 패널 · 상태막대를 안 그림(B07 평면도 관리 모달). */ bare?: boolean; originMarker?: boolean; + /** 작도 영역 가이드 — {area, marks?, editable?} · 없으면 가이드 없음(`guide-area.ts`). */ + guide?: unknown; } interface DrawingSaveRequestMessage { @@ -195,6 +201,8 @@ export function registerAisloDrawingBridge() { window.addEventListener('message', async (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== window.parent) return; if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장) + const guideMessage = event.data as { type?: string; area?: unknown }; + if (guideMessage?.type === AISLO_GUIDE_AREA_MESSAGE) return setGuideArea(guideMessage.area); const message = event.data as Partial; if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { try { @@ -232,6 +240,7 @@ export function registerAisloDrawingBridge() { setViewOnly(event.data.viewOnly === true); setBare(event.data.bare === true); setOriginMarker(event.data.originMarker === true); + setGuide(event.data.guide); if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: @@ -254,6 +263,7 @@ export function registerAisloDrawingBridge() { }); registerTextDoubleClickEdit(); registerBoxResizeDrag(); + registerGuideDrag((area) => notifyParent(AISLO_GUIDE_CHANGED_MESSAGE, { area })); notifyParent(AISLO_DRAWING_READY_MESSAGE); } diff --git a/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts b/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts new file mode 100644 index 000000000..366f4d1f2 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/guide-area.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + GUIDE_MIN_MM, + type GuideArea, + dragGuideArea, + getGuide, + pickGuideHandle, + setGuide, + setGuideArea, +} from './guide-area.ts'; + +const AREA: GuideArea = [42, 47, 812, 567]; + +describe('작도 영역 가이드(169-1 · 169-3)', () => { + it('싣기 — 어긋난 영역은 가이드 없음 · 갈기는 가이드가 있을 때만', () => { + setGuide({ area: [10, 10, 5, 20] }); + expect(getGuide()).toBeNull(); + setGuideArea(AREA); + expect(getGuide()).toBeNull(); + setGuide({ area: AREA, marks: [[0, 0, 1, 1], 'x'], editable: true }); + expect(getGuide()).toEqual({ area: AREA, marks: [[0, 0, 1, 1]], editable: true }); + setGuideArea([0, 0, 100, 50]); + expect(getGuide()?.area).toEqual([0, 0, 100, 50]); + setGuide(null); + expect(getGuide()).toBeNull(); + }); + + it('손잡이 — 모서리가 변보다 먼저 · 허용 거리 밖은 없음', () => { + expect(pickGuideHandle(AREA, { x: 812, y: 567 }, 2)).toBe('ne'); + expect(pickGuideHandle(AREA, { x: 43, y: 46 }, 2)).toBe('sw'); + expect(pickGuideHandle(AREA, { x: 400, y: 566 }, 2)).toBe('n'); + expect(pickGuideHandle(AREA, { x: 811, y: 300 }, 2)).toBe('e'); + expect(pickGuideHandle(AREA, { x: 400, y: 300 }, 2)).toBeNull(); + expect(pickGuideHandle(AREA, { x: 900, y: 567 }, 2)).toBeNull(); + }); + + it('끌기 — 반대편 그대로 · 0.1 mm 반올림 · 최소 크기', () => { + expect(dragGuideArea(AREA, 'ne', { x: 800.04, y: 560.06 })).toEqual([42, 47, 800, 560.1]); + expect(dragGuideArea(AREA, 'w', { x: 50, y: 0 })).toEqual([50, 47, 812, 567]); + expect(dragGuideArea(AREA, 's', { x: 0, y: 9999 })).toEqual([42, 567 - GUIDE_MIN_MM, 812, 567]); + expect(dragGuideArea(AREA, 'e', { x: -100, y: 0 })).toEqual([42, 47, 42 + GUIDE_MIN_MM, 567]); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/integration/guide-area.ts b/B07_DesignDetail/openwebcad/src/integration/guide-area.ts new file mode 100644 index 000000000..953678ac9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/guide-area.ts @@ -0,0 +1,178 @@ +import { Point } from '@flatten-js/core'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; +import { getCanvas, getScreenCanvasDrawController } from '../state.ts'; + +/** + * 작도 영역 가이드(169-1 · 169-3) — 도면 그림이 실제로 들어가는 사각형을 점선으로 보인다. + * + * 도면 개체가 아니라 그리기 겹판(기준점 십자와 같은 자리)이라 저장 · DXF · 인쇄에 안 섞인다. + * `editable` 이면 모서리 · 변을 끌어 영역을 고치고, 고친 값은 부모(숫자 칸)에 알린다. + * 부모 짝 = `ui_template/cad_host/cad_host.ts` `guide` 곁값 · `setGuideArea` · `onGuideChange`. + */ + +/** [왼쪽 x, 아래 y, 오른쪽 x, 위 y] — 도면 좌표 mm */ +export type GuideArea = [number, number, number, number]; + +export interface Guide { + area: GuideArea; + /** 넘친 칸(169-2) — 붉은 사각형으로 짚음 */ + marks?: GuideArea[]; + /** 끌어서 고칠 수 있나 — 도각 편집 · M02 양식만 */ + editable?: boolean; +} + +/** 끌 손잡이 — 모서리 넷 · 변 넷 (n = 위 · s = 아래 · e = 오른쪽 · w = 왼쪽) */ +export type GuideHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; + +const GUIDE_COLOR = '#29b6f6'; +const MARK_COLOR = '#ff5252'; +/** 손잡이를 집었다고 볼 화면 거리(px) — 칸 모서리 끌기(`box-resize-drag`)와 같음 */ +const GRAB_PIXELS = 9; +/** 영역 최소 폭 · 높이(mm) — 뒤집히거나 0 이 되지 않게 */ +export const GUIDE_MIN_MM = 10; + +let guide: Guide | null = null; + +const isArea = (value: unknown): value is GuideArea => + Array.isArray(value) && + value.length === 4 && + value.every((n) => typeof n === 'number' && Number.isFinite(n)) && + value[0] < value[2] && + value[1] < value[3]; + +export const getGuide = (): Guide | null => guide; + +/** 싣기 때 — 어긋난 값이면 가이드 없음 */ +export function setGuide(next: unknown): void { + const candidate = next as Partial | null | undefined; + guide = + candidate && isArea(candidate.area) + ? { + area: [...candidate.area] as GuideArea, + marks: (candidate.marks ?? []).filter(isArea), + editable: candidate.editable === true, + } + : null; +} + +/** 부모 숫자 칸이 바꾼 영역 — 가이드가 있을 때만 */ +export function setGuideArea(area: unknown): void { + if (guide && isArea(area)) guide = { ...guide, area: [...area] as GuideArea }; +} + +/** 점에서 가장 가까운 손잡이 — 모서리가 변보다 먼저 · 거리 `tolerance` 밖이면 null */ +export function pickGuideHandle( + area: GuideArea, + point: { x: number; y: number }, + tolerance: number +): GuideHandle | null { + const [x0, y0, x1, y1] = area; + const nearX = (x: number) => Math.abs(point.x - x) <= tolerance; + const nearY = (y: number) => Math.abs(point.y - y) <= tolerance; + const inX = point.x >= x0 - tolerance && point.x <= x1 + tolerance; + const inY = point.y >= y0 - tolerance && point.y <= y1 + tolerance; + const ns = nearY(y1) && inX ? 'n' : nearY(y0) && inX ? 's' : ''; + const ew = nearX(x1) && inY ? 'e' : nearX(x0) && inY ? 'w' : ''; + return ((ns + ew) as GuideHandle) || null; +} + +/** 손잡이를 점까지 끈 영역 — 반대편은 그대로 · 최소 크기 지킴 · 0.1 mm 반올림 */ +export function dragGuideArea( + area: GuideArea, + handle: GuideHandle, + point: { x: number; y: number } +): GuideArea { + const round = (n: number) => Math.round(n * 10) / 10; + let [x0, y0, x1, y1] = area; + if (handle.includes('w')) x0 = Math.min(round(point.x), x1 - GUIDE_MIN_MM); + if (handle.includes('e')) x1 = Math.max(round(point.x), x0 + GUIDE_MIN_MM); + if (handle.includes('s')) y0 = Math.min(round(point.y), y1 - GUIDE_MIN_MM); + if (handle.includes('n')) y1 = Math.max(round(point.y), y0 + GUIDE_MIN_MM); + return [x0, y0, x1, y1]; +} + +function strokeRect(drawController: ScreenCanvasDrawController, area: GuideArea): void { + const [x0, y0, x1, y1] = area; + const corners = [new Point(x0, y0), new Point(x1, y0), new Point(x1, y1), new Point(x0, y1)].map( + (point) => drawController.worldToTarget(point) + ); + corners.forEach((corner, index) => + drawController.drawLineScreen(corner, corners[(index + 1) % corners.length]) + ); +} + +/** 그리기 겹판 — 엔티티 장면 위에 매 장 그림(`helpers/draw.ts`) */ +export function drawGuide(drawController: ScreenCanvasDrawController): void { + if (!guide) return; + drawController.setOpacity(0.9); + drawController.setLineStyles(false, false, GUIDE_COLOR, 1, [8, 5]); + strokeRect(drawController, guide.area); + if (guide.editable) { + const [x0, y0, x1, y1] = guide.area; + const xs = [x0, (x0 + x1) / 2, x1]; + const ys = [y0, (y0 + y1) / 2, y1]; + for (const x of xs) { + for (const y of ys) { + if (x === xs[1] && y === ys[1]) continue; + const at = drawController.worldToTarget(new Point(x, y)); + drawController.fillRectScreen(at.x - 3, at.y - 3, 6, 6, GUIDE_COLOR, true); + } + } + } + drawController.setLineStyles(false, false, MARK_COLOR, 2, []); + for (const mark of guide.marks ?? []) strokeRect(drawController, mark); + drawController.setOpacity(1); +} + +/** 마우스 위치(화면) → 도면 좌표. 캐드는 화면 y 를 아래에서 위로 잰다. */ +function worldAt(event: MouseEvent): Point | null { + const canvas = getCanvas(); + if (!canvas) return null; + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + return getScreenCanvasDrawController().targetToWorld(screenPoint); +} + +let dragging: GuideHandle | null = null; + +/** 끌기 — 손잡이를 집었을 때만 가로챔(그 밖은 그리기 도구로 흘림) · 움직일 때마다 `notify` */ +export function registerGuideDrag(notify: (area: GuideArea) => void): void { + const canvas = getCanvas(); + if (!canvas) return; + window.addEventListener( + 'mousedown', + (event: MouseEvent) => { + if (event.button !== 0 || event.target !== canvas || !guide?.editable) return; + const world = worldAt(event); + if (!world) return; + const tolerance = GRAB_PIXELS / getScreenCanvasDrawController().getScreenScale(); + dragging = pickGuideHandle(guide.area, world, tolerance); + if (!dragging) return; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); + window.addEventListener( + 'mousemove', + (event: MouseEvent) => { + if (!dragging || !guide) return; + const world = worldAt(event); + if (!world) return; + guide = { ...guide, area: dragGuideArea(guide.area, dragging, world) }; + notify(guide.area); + event.stopImmediatePropagation(); + }, + true + ); + window.addEventListener( + 'mouseup', + (event: MouseEvent) => { + if (!dragging) return; + dragging = null; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 303480ae6..8e6d172de 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -12,9 +12,10 @@ import "./M02_MasterTemplete_Drawing.css"; import { API_BASE_URL } from "@config/config_frontend"; -import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { createCadHost, type CadGuideArea, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { createAreaFields, DEFAULT_DRAWING_AREA as DEFAULT_AREA } from "@ui/cad_host/cad_host_area"; import { createCadTabs, type CadTab, type CadTabs } from "@ui/cad_host/cad_host_tabs"; -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; /** 도면 양식 문서 — 작도 영역은 [x0, y0, x1, y1] (양식 좌표 mm). */ @@ -105,15 +106,13 @@ export interface DrawingTemplateHandle { destroy: () => void; } -/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */ -const DEFAULT_AREA: [number, number, number, number] = [42, 47, 812, 567]; -const AREA_LABELS = ["왼쪽 x", "아래 y", "오른쪽 x", "위 y"]; - /** 얹은 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 · 돌려줄 때 이 층을 통째로 뺌 */ const FRAME_LAYER_ID = "m02-structure-frame"; interface StructureFrame { entities: Record[]; layer: Record | null; + /** 작도 영역(도각과 같은 좌표) — CAD 가이드 선(169-4 · 보기만) */ + area?: CadGuideArea | null; } const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false }; const framePromises = new Map>(); @@ -181,42 +180,31 @@ export function mountDrawingTemplate( // 작도 영역 — 도면 내용이 이 칸 한가운데에 놓인다. 제목 줄에는 값이 다 보이는 단추 하나, // 누르면 칸 넷이 작은 창으로 펼쳐진다(칸은 창을 닫아도 그대로 살아 있음). - const areaForm = document.createElement("div"); - areaForm.className = "m02-drawing__areaform"; - const start = doc.drawing_area ?? DEFAULT_AREA; - const areaInputs = AREA_LABELS.map((label, index) => { - const field = createInputField({ label, type: "number", value: String(start[index]) }); - field.input.disabled = readOnly; - field.input.addEventListener("input", () => { - options.onChanged?.(true); - readArea(); - }); - return field; - }); - areaForm.append(...areaInputs.map((field) => field.root)); - + // 칸 ↔ CAD 가이드 사각형 끌기가 한 벌(169-3 · 공용 칸 `cad_host_area`). const areaButton = createButton({ label: "", variant: "ghost" }); areaButton.classList.add("m02-drawing__areabtn"); - areaButton.title = "작도 영역 — 도면 내용이 놓이는 칸(양식 좌표 mm) · 눌러서 고침"; + areaButton.title = "작도 영역 — 도면 내용이 놓이는 칸(양식 좌표 mm) · 눌러서 고침 · 점선 끌기"; let areaModal: { close: () => void } | null = null; - - const readArea = (): [number, number, number, number] | null => { - const values = areaInputs.map((field) => Number(field.input.value)); - const valid = - areaInputs.every((field) => field.input.value.trim() !== "") && - values.every(Number.isFinite) && - values[0] < values[2] && - values[1] < values[3]; - // 빈 칸 · 숫자 아님은 그 칸만 짚음 — 모두 숫자인데 어긋나면 크기 순서 안내 - const own = areaInputs.map((field) => { - const raw = field.input.value.trim(); - return raw === "" ? "값을 넣을 것" : Number.isFinite(Number(raw)) ? undefined : "숫자만"; - }); - const order = own.some(Boolean) ? undefined : "왼쪽<오른쪽 · 아래<위"; - areaInputs.forEach((field, index) => field.setError(valid ? undefined : (own[index] ?? order))); - areaButton.textContent = `작도 영역 ${areaInputs.map((f) => f.input.value.trim() || "?").join(" · ")}`; - areaButton.classList.toggle("is-invalid", !valid); - return valid ? (values as [number, number, number, number]) : null; + const showArea = (area: CadGuideArea | null): void => { + areaButton.textContent = area + ? `작도 영역 ${area.map((value) => Math.round(value * 10) / 10).join(" · ")}` + : "작도 영역 ?"; + areaButton.classList.toggle("is-invalid", !area); + }; + const areaFields = createAreaFields(doc.drawing_area ?? DEFAULT_AREA, { + readOnly, + onInput: (area) => { + options.onChanged?.(true); + showArea(area); + if (area) cad.setGuideArea(area); + }, + }); + const areaForm = areaFields.element; + areaForm.classList.add("m02-drawing__areaform"); + const readArea = (): CadGuideArea | null => { + const area = areaFields.read(); + showArea(area); + return area; }; readArea(); areaButton.addEventListener("click", () => { @@ -257,6 +245,11 @@ export function mountDrawingTemplate( onChanged: (dirty) => options.onChanged?.(dirty), onHostSave: hostSave, onPlaceCancel: () => options.onPlaceCancel?.(), + onGuideChange: (area) => { + areaFields.set(area); + showArea(area); + options.onChanged?.(true); + }, }); let loadSeq = 0; /** `after` = 싣기를 보낸 바로 뒤(이 싣기가 마지막일 때만) — 표 놓기를 「열린 뒤」로 미룰 때 */ @@ -288,6 +281,12 @@ export function mountDrawingTemplate( panelsCollapsed: options.panelsCollapsed, viewOnly: options.preview === true, originMarker: options.originMarker === true, + // 가이드 선 — 양식은 작도 영역(끌기 · 169-3) · 구조물은 얹은 도각의 영역(보기만 · 169-4) + guide: frame?.area + ? { area: frame.area } + : noArea + ? null + : { area: readArea() ?? DEFAULT_AREA, editable: !readOnly }, }, ); after?.(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py index 959d054f1..c296b57a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py @@ -17,6 +17,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, frame_document, frame_entities, + frame_guide_area, usable_bbox, validate_template_entities, ) @@ -119,6 +120,8 @@ def structure_frame(number: str | None = None) -> dict: ) return { "entities": [_on_frame_layer(entity) for entity in entities], + # 작도 영역(도각과 같은 좌표) — CAD 가이드 선(169-4 · 보기만) + "area": frame_guide_area(entities), "layer": { "id": STRUCTURE_FRAME_LAYER, "name": "도각(보기)", diff --git a/resources/tester/test_169_area_fields.py b/resources/tester/test_169_area_fields.py new file mode 100644 index 000000000..0c63c3e15 --- /dev/null +++ b/resources/tester/test_169_area_fields.py @@ -0,0 +1,129 @@ +"""169-1 · 169-3 작도 영역 숫자 칸(`cad_host_area.ts`) · B07 도각 편집(`cad_host_frame_edit.ts`) +— Node 로 TS 를 돌려 판정(가짜 DOM). + +못박는 것 + - 칸 = 왼쪽 · 위 · 폭 · 높이 ↔ 저장 꼴 [x0, y0, x1, y1] · 폭 · 높이 0 이하 거절(그 칸만 짚음). + - 도각 편집 = 서버 영역을 칸 · 가이드(끌기 켬)로 싣고 · 끌기 → 칸 · 칸 → CAD 갈기 · [완료] 때 영역 실어 저장. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = r""" +const fs = require("fs"); +const root = process.argv[1]; +const ts = require(root + "/config/node_modules/typescript"); +const load = (file, stubs) => { + const src = fs.readFileSync(root + "/" + file, "utf8"); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); + return box.exports; +}; +const node = () => ({ + kids: [], hidden: false, textContent: "", className: "", disabled: false, on: {}, + classList: { add() {} }, append(...x) { this.kids.push(...x); }, + addEventListener(k, f) { this.on[k] = f; }, +}); +globalThis.document = { createElement: node }; +const inputs = []; +const errors = []; +const elements = { + showToast: () => {}, + createButton: (o) => Object.assign(node(), { click: o.onClick }), + createInputField: (o) => { + const input = Object.assign(node(), { value: o.value }); + const index = inputs.push(input) - 1; + return { root: node(), input, setError: (e) => (errors[index] = e) }; + }, +}; +const area = load("ui_template/cad_host/cad_host_area.ts", { "@ui/ui_template_elements": elements }); +const edit = load("ui_template/cad_host/cad_host_frame_edit.ts", { + "@ui/ui_template_elements": elements, + "./cad_host_area": area, +}); + +(async () => { + const out = {}; + out.toFields = area.areaToFields([42, 47, 812, 567]); + out.toArea = area.fieldsToArea([42, 567, 770, 520]); + out.zero = area.fieldsToArea([0, 10, 0, 5]); + + const loads = []; + const guides = []; + const saved = []; + const editor = edit.createFrameTemplateEditor({ + api: { + fetch: async () => ({ drawing: { entities: [], drawing_area: [10, 20, 410, 320] }, customized: false }), + save: async (drawing) => saved.push(drawing), + importFile: async () => ({ drawing: { entities: [] }, entity_count: 0 }), + reset: async () => {}, + }, + sendLoad: (drawing, meta, frameEdit, fields, extra) => loads.push(extra), + setGuideArea: (a) => guides.push(a), + requestCadDrawing: async () => ({ entities: [{ id: "x" }] }), + restoreDrawing: () => {}, + onSaved: () => {}, + currentDrawingInfo: () => null, + }); + const fields = inputs.slice(-4); + await editor.button.click(); + out.loaded = loads[0]; + out.shown = fields.map((f) => f.value); + editor.guideChanged([10, 20, 300.04, 320]); + out.dragged = fields.map((f) => f.value); + fields[3].value = "100"; + fields[3].on.input(); + out.typed = guides.slice(); + fields[2].value = "0"; + fields[2].on.input(); + out.zeroError = errors.slice(-4); + fields[2].value = "290"; + fields[2].on.input(); + await editor.banner.kids[3].kids[0].click(); // [완료] + out.saved = saved; + console.log(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def result() -> dict: + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + proc = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_fields_round_trip(result): + assert result["toFields"] == [42, 567, 770, 520] + assert result["toArea"] == [42, 47, 812, 567] + assert result["zero"] is None + + +def test_frame_edit_loads_area_and_guide(result): + assert result["loaded"] == {"guide": {"area": [10, 20, 410, 320], "editable": True}} + assert result["shown"] == ["10", "320", "400", "300"] + # 끌기 → 칸(0.1 mm) + assert result["dragged"] == ["10", "320", "290", "300"] + + +def test_typing_updates_guide_and_flags_zero(result): + # 높이 100 → [10, 220, 300, 320] (끌기 값 300.04 는 칸에서 300 으로) + assert result["typed"] == [[10, 220, 300, 320]] + assert result["zeroError"][2] == "0 보다 커야 함" and result["zeroError"][3] is None + + +def test_finish_saves_area_with_drawing(result): + assert result["saved"] == [{"entities": [{"id": "x"}], "drawing_area": [10, 220, 300, 320]}] diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index 4788a5d9f..86e912add 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -120,6 +120,7 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template "@config/config_frontend": { API_BASE_URL: "/api" }, "@ui/cad_host/cad_host": { createCadHost: () => fakeCad }, "@ui/cad_host/cad_host_tabs": tabsMod, + "@ui/cad_host/cad_host_area": load("ui_template/cad_host/cad_host_area.ts", { "@ui/ui_template_elements": elements }), "@ui/ui_template_elements": elements, "@ui/ui_template_modal": { openModal: () => ({ close() {} }) }, }); diff --git a/resources/tester/test_m02_drawing_area.py b/resources/tester/test_m02_drawing_area.py index 6acaf41e8..135270748 100644 --- a/resources/tester/test_m02_drawing_area.py +++ b/resources/tester/test_m02_drawing_area.py @@ -51,7 +51,7 @@ def test_template_area_moves_content(tmp_path): engine.use_project_templates(tmp_path) try: assert engine.drawing_area() == (0.0, 0.0, 400.0, 200.0) - assert engine.usable_area() == (400 * 0.96, 200 * 0.96) + assert engine.usable_area() == (400.0, 200.0) # 여백 없음(169-1) # 콘텐츠 중심(500, 500) 이 작도 영역 중심(200, 100) 에 온다 — 이동량 (300, 400). placed = engine.frame_entities("t", (490.0, 490.0, 510.0, 510.0), fit=False) assert placed[0]["shapeData"]["startPoint"] == {"x": 300.0, "y": 400.0} diff --git a/ui_template/cad_host/cad_host.css b/ui_template/cad_host/cad_host.css index a7900805d..282198718 100644 --- a/ui_template/cad_host/cad_host.css +++ b/ui_template/cad_host/cad_host.css @@ -102,3 +102,10 @@ padding: 4px 8px; font-size: 0.78rem; } + +/* 작도 영역 숫자 칸 넷(169-1 · `cad_host_area.ts`) — 두 줄 두 칸 */ +.cad-area-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index cd0f3d601..07801f0c7 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -33,6 +33,21 @@ const CAD_PLACE_TABLE_MESSAGE = "aislo:b08:place-table"; const CAD_UPDATE_TABLE_MESSAGE = "aislo:b08:update-table"; const CAD_TABLE_UPDATED_MESSAGE = "aislo:b08:table-updated"; const CAD_PLACE_CANCELLED_MESSAGE = "aislo:b08:place-cancelled"; +/** 작도 영역 가이드(169-1 · 169-3) — 짝 = CAD `integration/guide-area.ts` */ +const CAD_GUIDE_AREA_MESSAGE = "aislo:b08:guide-area"; +const CAD_GUIDE_CHANGED_MESSAGE = "aislo:b08:guide-changed"; + +/** 작도 영역 [왼쪽 x, 아래 y, 오른쪽 x, 위 y] — 도면 좌표 mm */ +export type CadGuideArea = [number, number, number, number]; + +/** CAD 가이드 — 점선 사각형(그리기 겹판 · 저장 · DXF · 인쇄 안 됨). */ +export interface CadGuide { + area: CadGuideArea; + /** 넘친 칸 — 붉은 사각형(169-2) */ + marks?: CadGuideArea[]; + /** 모서리 · 변 끌기 — 도각 편집 · M02 양식만(169-3) · 끌면 `onGuideChange` */ + editable?: boolean; +} /** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */ export interface CadHostDrawing { @@ -59,6 +74,8 @@ export interface CadHostOptions { onHostSave?: () => void; /** 표 놓기를 놓지 않고 Esc 로 그만둠(41장). */ onPlaceCancel?: () => void; + /** 가이드 사각형을 끌어 바꿈(169-3) — 움직일 때마다. */ + onGuideChange?: (area: CadGuideArea) => void; } /** 싣기 곁값 — 수량 패널(meta) 없이 싣는 M02 양식이 쓴다. */ @@ -81,6 +98,8 @@ export interface CadLoadExtra { bare?: boolean; /** 좌표 0,0 에 기준점 십자를 그린다(그리기 겹판 · 저장 안 됨) — 안 보내면 없음. */ originMarker?: boolean; + /** 작도 영역 가이드(169-1) — 안 보내면 없음. */ + guide?: CadGuide | null; } export interface CadHost { @@ -107,6 +126,8 @@ export interface CadHost { placeTable: (entity: Record, link: string) => void; /** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */ updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise; + /** 가이드 사각형만 갈기(숫자 칸 고침 · 169-3) — 도면은 다시 안 실음. */ + setGuideArea: (area: CadGuideArea) => void; /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ destroy: () => void; } @@ -266,6 +287,7 @@ export function createCadHost postAfterLoad({ type: CAD_GUIDE_AREA_MESSAGE, area }), destroy: () => { stopLoadWatchdog(); window.removeEventListener("message", onMessage); diff --git a/ui_template/cad_host/cad_host_area.ts b/ui_template/cad_host/cad_host_area.ts new file mode 100644 index 000000000..088684b55 --- /dev/null +++ b/ui_template/cad_host/cad_host_area.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * ui_template/cad_host/cad_host_area.ts + * 작도 영역 숫자 칸 한 벌(169-1) — M02 도면 양식 · B07 도각 편집이 함께 쓴다. + * + * 칸 = 왼쪽 · 위 · 폭 · 높이(mm) · 저장 꼴은 그대로 [x0, y0, x1, y1](양식 `drawing_area`). + * CAD 가이드 사각형 끌기(`cad_host` `onGuideChange`) ↔ 이 칸(`set`)이 한 벌로 움직인다. + * ========================================================================== */ + +import { createInputField } from "@ui/ui_template_elements"; +import type { CadGuideArea } from "./cad_host"; + +/** 작도 영역 칸이 없는 양식이 쓰는 값 — 서버 `Engine_Template._A1_INNER` 와 같다. */ +export const DEFAULT_DRAWING_AREA: CadGuideArea = [42, 47, 812, 567]; + +/** 칸 이름 — 왼쪽 x · 위 y(윗변) · 폭 · 높이 */ +const LABELS = ["왼쪽 (mm)", "위 (mm)", "폭 (mm)", "높이 (mm)"]; + +/** 0.1 mm 까지 — 끌기 값이 긴 소수로 안 보이게 */ +const show = (value: number): string => String(Math.round(value * 10) / 10); + +/** [x0, y0, x1, y1] → 칸 값 [왼쪽, 위, 폭, 높이] */ +export const areaToFields = ([x0, y0, x1, y1]: CadGuideArea): number[] => [ + x0, + y1, + x1 - x0, + y1 - y0, +]; + +/** 칸 값 [왼쪽, 위, 폭, 높이] → [x0, y0, x1, y1] · 폭 · 높이가 0 이하면 null */ +export const fieldsToArea = ([left, top, width, height]: number[]): CadGuideArea | null => + [left, top, width, height].every(Number.isFinite) && width > 0 && height > 0 + ? [left, top - height, left + width, top] + : null; + +export interface AreaFields { + /** 칸 넷 */ + element: HTMLElement; + /** 지금 값 — 어긋나면 null(칸마다 까닭 표시) */ + read: () => CadGuideArea | null; + /** 끌기 · 다시 싣기가 바꾼 값 — 칸만 고침(`onInput` 안 부름) */ + set: (area: CadGuideArea) => void; +} + +export function createAreaFields( + start: CadGuideArea, + options: { readOnly?: boolean; onInput?: (area: CadGuideArea | null) => void } = {}, +): AreaFields { + const element = document.createElement("div"); + element.className = "cad-area-fields"; + const fields = LABELS.map((label) => createInputField({ label, type: "number", value: "" })); + element.append(...fields.map((field) => field.root)); + + const read = (): CadGuideArea | null => { + const raws = fields.map((field) => field.input.value.trim()); + const own = raws.map((raw) => + raw === "" ? "값을 넣을 것" : Number.isFinite(Number(raw)) ? undefined : "숫자만", + ); + const area = own.some(Boolean) ? null : fieldsToArea(raws.map(Number)); + // 모두 숫자인데 어긋나면 폭 · 높이 중 0 이하인 칸만 짚음 + const size = (index: number): string | undefined => + own.some(Boolean) || index < 2 || Number(raws[index]) > 0 ? undefined : "0 보다 커야 함"; + fields.forEach((field, index) => field.setError(own[index] ?? size(index))); + return area; + }; + + const set = (area: CadGuideArea): void => { + areaToFields(area).forEach((value, index) => (fields[index].input.value = show(value))); + read(); + }; + + for (const field of fields) { + field.input.disabled = options.readOnly === true; + field.input.addEventListener("input", () => options.onInput?.(read())); + } + set(start); + return { element, read, set }; +} diff --git a/ui_template/cad_host/cad_host_frame_edit.ts b/ui_template/cad_host/cad_host_frame_edit.ts index 6bc2597a6..f82c54c09 100644 --- a/ui_template/cad_host/cad_host_frame_edit.ts +++ b/ui_template/cad_host/cad_host_frame_edit.ts @@ -10,7 +10,8 @@ */ import { createButton, showToast } from "@ui/ui_template_elements"; -import type { CadHostDrawing } from "./cad_host"; +import type { CadGuideArea, CadHostDrawing, CadLoadExtra } from "./cad_host"; +import { createAreaFields, DEFAULT_DRAWING_AREA } from "./cad_host_area"; /** 도각을 읽고 쓰는 길 — 페이지가 자기 서버 길로 채운다. */ export interface FrameTemplateApi { @@ -30,6 +31,8 @@ export interface FrameTemplateEditor { banner: HTMLElement; /** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */ isEditing: () => boolean; + /** CAD 가이드 사각형을 끌었음(169-3) — 페이지가 `cad_host` `onGuideChange` 로 잇는다. */ + guideChanged: (area: CadGuideArea) => void; } interface Options { @@ -41,7 +44,10 @@ interface Options { meta: null, frameEdit?: boolean, frameFields?: Record, + extra?: CadLoadExtra, ) => void; + /** 숫자 칸을 고친 영역을 CAD 가이드에 갈기(`cad_host` `setGuideArea`). */ + setGuideArea: (area: CadGuideArea) => void; /** CAD에서 현재 편집본을 받아온다. */ requestCadDrawing: () => Promise; /** 편집을 마친 뒤 보던 도면으로 돌아간다. */ @@ -64,7 +70,18 @@ export function createFrameTemplateEditor( banner.hidden = true; const label = document.createElement("span"); label.className = "cad-frame-edit__label"; - banner.append(label); + // 작도 영역(169-1) — 도면 그림이 들어가는 칸 · CAD 점선 끌기와 한 벌 · [완료] 때 함께 저장 + const areaFields = createAreaFields(DEFAULT_DRAWING_AREA, { + onInput: (area) => area && options.setGuideArea(area), + }); + const areaNote = document.createElement("span"); + areaNote.className = "cad-frame-edit__label"; + areaNote.textContent = + "작도 영역 = 점선 사각형(끌거나 숫자로) · 여백 없이 그대로 씀 — 확정 안 한 도면은 장 나눔이 바뀔 수 있음"; + banner.append(label, areaFields.element, areaNote); + const guideOf = (): CadLoadExtra => ({ + guide: { area: areaFields.read() ?? DEFAULT_DRAWING_AREA, editable: true }, + }); const finishButton = createButton({ label: "완료", @@ -127,7 +144,7 @@ export function createFrameTemplateEditor( importButton.disabled = true; try { const response = await options.api.importFile(file); - options.sendLoad(response.drawing, null, true, frameFields); + options.sendLoad(response.drawing, null, true, frameFields, guideOf()); label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`; showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); } catch (error) { @@ -155,7 +172,9 @@ export function createFrameTemplateEditor( label.textContent = response.customized ? "도각 편집 중 — 이 프로젝트에서 고친 도각입니다." : "도각 편집 중 — 고치면 이 프로젝트 도각으로 저장됩니다."; - options.sendLoad(response.drawing, null, true, frameFields); + const area = (response.drawing as { drawing_area?: CadGuideArea }).drawing_area; + areaFields.set(area ?? DEFAULT_DRAWING_AREA); + options.sendLoad(response.drawing, null, true, frameFields, guideOf()); } catch (error) { showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error"); } @@ -189,7 +208,9 @@ export function createFrameTemplateEditor( if (!editing) return; finishButton.disabled = true; try { - const drawing = await options.requestCadDrawing(); + const area = areaFields.read(); + if (!area) throw new Error("작도 영역 값이 올바르지 않습니다."); + const drawing = { ...(await options.requestCadDrawing()), drawing_area: area }; await options.api.save(drawing); options.onSaved(); showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success"); @@ -201,5 +222,10 @@ export function createFrameTemplateEditor( } } - return { button, banner, isEditing: () => editing }; + return { + button, + banner, + isEditing: () => editing, + guideChanged: (area) => editing && areaFields.set(area), + }; } From ab92de37870320c122a669816aaebf7998fd6665 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:35:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(b07):=20=ED=9A=A1=EB=8B=A8=20=EC=9E=A5?= =?UTF-8?q?=20=3D=20=EC=9E=91=EB=8F=84=20=EC=98=81=EC=97=AD=20=EC=97=B4=20?= =?UTF-8?q?=C3=97=20=ED=96=89=20=ED=91=9C=20=EA=B2=A9=EC=9E=90=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EC=9E=A5=20=EC=95=88=20=EB=B8=94=EB=A1=9D=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=EB=81=94=20=C2=B7=20=EC=B9=B8=20=EB=84=98?= =?UTF-8?q?=EC=B9=A8=20=EC=95=8C=EB=A6=BC=20(169-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장마다 칸 격자선(세로 열 + 1 · 가로 행 + 1 · 도각 층 · 인쇄 · DXF) - 단면은 블록 틀 가운데 = 칸 가운데 그대로 · 장 안 블록 테두리 안 그림(측점 한 장은 그대로) - 칸보다 큰 단면 = overflow_cells → CAD 가이드 붉은 틀 + 화면 알림(축척 그대로) - 배치 판 4 — 옛 판 확정 장은 「옛 배치」 안내 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL --- .../B07_DesignDetail_Engine_Cad.py | 8 +- .../B07_DesignDetail_Engine_Cad_Sheet.py | 51 ++++++- .../B07_DesignDetail_Router_Support.py | 5 +- .../tester/test_169_drawing_area_guide.py | 130 ++++++++++++++++++ 4 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_169_drawing_area_guide.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index e8f696edb..707014f23 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -496,6 +496,7 @@ def build_cross_drawing( frame: dict[str, float] | None = None, origin: tuple[float, float] = (0.0, 0.0), overlay: tuple[float, float, float, float] | None = None, + border: bool = True, ) -> dict[str, Any]: """횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다. @@ -586,9 +587,10 @@ def build_cross_drawing( (frame_left, frame_top), (frame_left, frame_bottom), ] - border = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) - if border: - entities.append(border) + # 장 안 블록은 테두리 대신 장 칸 격자선(169-2) — 측점 한 장만 테두리 + outline = polyline_entity(drawing_id, corners, FRAME_LAYER_ID, TABLE_LINE_COLOR) + if border and outline: + entities.append(outline) return { "format": DRAWING_FORMAT, diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 3758fbd04..709527a3d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -3,6 +3,7 @@ 채우는 순서는 **좌하단부터 아래에서 위로, 그다음 오른쪽 열**이다 (2026-08-29 사용자 확정). 칸 = 도각 그림칸(usable_area)을 똑같이 나눈 자리 · 단면은 칸 가운데 · 작성 척도 1/100 그대로(큰 단면이 칸을 넘으면 겹침 — 사용자 배치로 고침). +칸 = 인쇄되는 표 격자선 · 블록 테두리 없음 · 칸보다 큰 단면은 `overflow_cells`(169-2). """ import re @@ -19,11 +20,13 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( GROUND_LAYER_ID, ROCK_LAYER_ID, STRUCTURE_LAYER_ID, + TABLE_LINE_COLOR, _clip_polyline, _layer, _section_window, build_cross_drawing, points_from_samples, + polyline_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( cross_note_height, @@ -145,11 +148,37 @@ def plan_cross_sheets( return sheets +def _grid_lines(sheet_id: str, cols: int, rows: int) -> list[dict[str, Any]]: + """장 칸 격자선(169-2) — 세로 열 + 1 · 가로 행 + 1 · 도각 층(인쇄 · DXF 에 나감).""" + usable_w, usable_h = usable_area() + left, bottom = -usable_w / 2.0, -usable_h / 2.0 + lines = [ + ((left + usable_w * i / cols, bottom), (left + usable_w * i / cols, -bottom), f"v{i}") + for i in range(cols + 1) + ] + [ + ((left, bottom + usable_h * j / rows), (-left, bottom + usable_h * j / rows), f"h{j}") + for j in range(rows + 1) + ] + return [ + entity + for start, end, name in lines + if ( + entity := polyline_entity( + sheet_id, [start, end], FRAME_LAYER_ID, TABLE_LINE_COLOR, f":grid-{name}" + ) + ) + ] + + 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]] = [] slots = sheet.get("slots") or [] + cols, rows = sheet.get("grid") or (4, 4) + usable_w, usable_h = usable_area() + cell_w, cell_h = usable_w / cols, usable_h / rows for index, section in enumerate(sections): if index >= len(slots): @@ -164,13 +193,26 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> section.get("quantity_table"), section.get("title", ""), overlay=section.get("overlay"), + border=False, ) - bbox = entities_bbox(probe["entities"]) + # 블록 틀(그림 + 1 mm · 안 그림) — 테두리를 끈 뒤에도 같은 자리 · 크기로 칸에 맞춤 + frames = [p.get("frame") for p in probe.get("cross_placements") or []] + bbox = frames[0] if frames and frames[0] else entities_bbox(probe["entities"]) if bbox is None: continue min_x, min_y, max_x, max_y = bbox center_x, center_y = slots[index] - # 2) 블록(테두리 = 자기 그림 + 1 mm) 가운데를 칸 가운데에 맞춰 다시 만든다. + # 칸보다 큰 단면 — 축척 그대로 두고 그 칸을 화면 가이드로 짚음(169-2) + if max_x - min_x > cell_w + 1e-6 or max_y - min_y > cell_h + 1e-6: + overflow.append( + [ + center_x - cell_w / 2.0, + center_y - cell_h / 2.0, + center_x + cell_w / 2.0, + center_y + cell_h / 2.0, + ] + ) + # 2) 블록 그림 가운데를 칸 가운데에 맞춰 다시 만든다(장 안은 테두리 없음). origin = (center_x - (min_x + max_x) / 2.0, center_y - (min_y + max_y) / 2.0) placed = build_cross_drawing( section["source"], @@ -181,13 +223,14 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> section.get("title", ""), origin=origin, overlay=section.get("overlay"), + border=False, ) entities.extend(placed["entities"]) placements.extend(placed.get("cross_placements") or []) if entities: + entities.extend(_grid_lines(sheet["id"], cols, rows)) # 도각 = 고정 그림칸 — 내용 크기와 무관하게 칸 격자와 같은 자리(140-3). - usable_w, usable_h = usable_area() entities.extend( frame_entities( sheet["id"], @@ -201,6 +244,8 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> "format": DRAWING_FORMAT, "entities": entities, "cross_placements": placements, + # 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2) + "overflow_cells": overflow, "layers": [ _layer(GROUND_LAYER_ID, "원지반", locked=True), _layer(DESIGN_LAYER_ID, "계획선"), diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index ddf43b53b..6c8243cff 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -97,8 +97,9 @@ MASS_HAUL_ID = "mass_haul" COVER_ID = "cover" # 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장. CROSS_STANDARD_ID = "cross_standard" -# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3). 이 판 전에 확정한 장은 옛 배치다. -CROSS_SHEET_LAYOUT = 3 +# 횡단 장 배치 판 — 장마다 열 × 행 칸 가운데(140-3) · 칸 격자선 · 블록 테두리 없음(169-2). +# 이 판 전에 확정한 장은 옛 배치다. +CROSS_SHEET_LAYOUT = 4 # 장마다 열 × 행 — {default: [열, 행], sheets: {장 번호: [열, 행]}}(140-3). CROSS_LAYOUT_FILE = "cross_layout.json" CROSS_GRID_DEFAULT = [4, 4] diff --git a/resources/tester/test_169_drawing_area_guide.py b/resources/tester/test_169_drawing_area_guide.py new file mode 100644 index 000000000..1e2e28ffe --- /dev/null +++ b/resources/tester/test_169_drawing_area_guide.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""169 도면 그림 영역 지정 · 횡단 격자 표. + +못박는 것 + - 169-1 그림칸 = 작도 영역 그대로(여백 없음) · 도각 편집이 영역을 싣고 · 저장(어긋나면 옛 값). + - 169-1 도면 좌표 가이드 영역 = 도각 bbox ↔ 양식 bbox 로 옮긴 영역 · 도각 아닌 것(블록 틀)이면 None. + - 169-2 횡단 장 = 칸 격자선(열 + 1 · 행 + 1 · 칸 경계) · 장 안 블록 테두리 없음 · 큰 단면 → 넘친 칸. + - 169-4 구조물 도각(외곽 좌하단 0,0) 응답에 영역. +""" + +import json +from pathlib import Path +from uuid import uuid5 + +import pytest + +from B07_DesignDetail import B07_DesignDetail_Engine_Template as engine +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( + _POLY_NS, + FRAME_LAYER_ID, + build_cross_drawing, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import build_cross_sheet, plan_cross_sheets +from B07_DesignDetail.B07_DesignDetail_Router import _drawing_guide +from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame + +ROOT = Path(__file__).resolve().parents[2] +TABLE = {"ground": 100.0, "planned": 100.0} + + +def _source(chainage: float, slope: float, half: int = 10) -> dict: + return { + "chainage_m": chainage, + "samples": [ + {"valid": True, "offset_m": float(x), "elevation_m": 100.0 + slope * x} + for x in range(-half, half + 1) + ], + } + + +def _sheet(sources: list[dict], layout: dict | None = None) -> tuple[dict, dict]: + sheet = plan_cross_sheets([s["chainage_m"] for s in sources], layout)[0] + sections = [ + {"chainage": s["chainage_m"], "source": s, "quantity_table": TABLE, "title": ""} + for s in sources + ] + return sheet, build_cross_sheet(sheet, sections) + + +def test_usable_area_is_drawing_area_without_margin(): + engine.use_project_templates(None) + x0, y0, x1, y1 = engine.drawing_area() + assert engine.usable_area() == (x1 - x0, y1 - y0) == (770.0, 520.0) + + +def test_frame_edit_loads_and_saves_area(tmp_path): + engine.use_project_templates(None) + assert engine.frame_template_document()["drawing_area"] == [42.0, 47.0, 812.0, 567.0] + path = engine.save_project_template(tmp_path, [], area=[10, 20, 410, 320]) + assert json.loads(path.read_text(encoding="utf-8"))["drawing_area"] == [10, 20, 410, 320] + # 어긋난 값(뒤집힘 · 글자) → 고치기 전 값을 이어 받음 + for bad in ([410, 20, 10, 320], ["a", 0, 1, 1], None): + path = engine.save_project_template(tmp_path, [], area=bad) + assert json.loads(path.read_text(encoding="utf-8"))["drawing_area"] == [10, 20, 410, 320] + # 라우터가 요청 도면의 영역을 넘김 + router = (ROOT / "B07_DesignDetail/B07_DesignDetail_Router_Frame.py").read_text( + encoding="utf-8" + ) + assert 'request.drawing.get("drawing_area")' in router and "area=area" in router + + +def test_cross_sheet_grid_lines_no_block_border_and_guide_area(): + engine.use_project_templates(None) + w, h = engine.usable_area() + sources = [_source(i * 20.0, 0.05, 6) for i in range(3)] + sheet, drawing = _sheet(sources, {"default": [3, 2]}) + by_id = {e["id"]: e for e in drawing["entities"]} + + def line(name: str) -> tuple[dict, dict]: + entity = by_id[str(uuid5(_POLY_NS, f"{sheet['id']}:{FRAME_LAYER_ID}:grid-{name}"))] + assert entity["layerId"] == FRAME_LAYER_ID + shape = entity["children"][0]["shapeData"] + return shape["startPoint"], shape["endPoint"] + + # 격자선 = 세로 열 + 1 · 가로 행 + 1 · 칸 경계 자리 · 그림칸 끝에서 끝까지 + for i in range(3 + 1): + a, b = line(f"v{i}") + assert a["x"] == b["x"] == pytest.approx(-w / 2 + w * i / 3) + assert (a["y"], b["y"]) == pytest.approx((-h / 2, h / 2)) + for j in range(2 + 1): + a, b = line(f"h{j}") + assert a["y"] == b["y"] == pytest.approx(-h / 2 + h * j / 2) + assert (a["x"], b["x"]) == pytest.approx((-w / 2, w / 2)) + assert str(uuid5(_POLY_NS, f"{sheet['id']}:{FRAME_LAYER_ID}:grid-v4")) not in by_id + # 장 안 블록 테두리 없음(측점 한 장 테두리와 같은 id 자리) + for source in sources: + seed = f"{sheet['id']}:{source['chainage_m']}:{FRAME_LAYER_ID}" + assert str(uuid5(_POLY_NS, seed)) not in by_id + # 가이드 영역(도면 좌표) = 그림칸 그대로 · 넘침 없음 + guide = _drawing_guide(drawing) + assert guide["area"] == pytest.approx([-w / 2, -h / 2, w / 2, h / 2], abs=1e-3) + assert guide["marks"] == [] and drawing["overflow_cells"] == [] + + +def test_big_section_marks_its_cell(): + engine.use_project_templates(None) + w, h = engine.usable_area() + # 4 × 4 칸(192.5 × 130 mm)에 60 m 폭 단면(600 mm) — 첫 칸(좌하단)만 넘침 + sources = [_source(0.0, 0.05, 30), _source(20.0, 0.05, 6)] + _sheet_, drawing = _sheet(sources, {"default": [4, 4]}) + cw, ch = w / 4, h / 4 + assert drawing["overflow_cells"] == [pytest.approx([-w / 2, -h / 2, -w / 2 + cw, -h / 2 + ch])] + assert _drawing_guide(drawing)["marks"] == drawing["overflow_cells"] + + +def test_station_drawing_border_is_not_a_frame(): + # 측점 한 장 — 도각 층에 블록 테두리만 있음 → 가이드 없음(틀린 선 대신) + drawing = build_cross_drawing(_source(0.0, 0.1), "cross_00000m", None, None, TABLE, "") + assert any(e.get("layerId") == FRAME_LAYER_ID for e in drawing["entities"]) + assert _drawing_guide(drawing) is None + + +def test_structure_frame_area_in_frame_coords(): + engine.use_project_templates(None) + frame = structure_frame() + min_x, min_y, *_ = engine.entities_bbox(engine.template_entities()) + x0, y0, x1, y1 = engine.drawing_area() + assert frame["area"] == pytest.approx( + [x0 - min_x, y0 - min_y, x1 - min_x, y1 - min_y], abs=1e-3 + )