From b1593bce835314805c1fc789cfed662acafacee1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 08:35:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(B07):=20=ED=91=9C=EC=A0=9C=EB=9E=80?= =?UTF-8?q?=EC=97=90=20=EB=A1=9C=EA=B3=A0=C2=B7=EC=84=9C=EB=AA=85=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EC=8B=A0=EC=84=A4=20+=20=EC=8B=9C?= =?UTF-8?q?=ED=96=89=EC=B2=AD=C2=B7=EB=8B=B4=EB=8B=B9=EC=9E=90=20DB=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-02) — 로고·서명을 임의로 만들어 표제란에 넣고, 위치·크기는 도각을 고치면 따라오게 하며, 시행청·과업책임자 등은 DB를 넓혀 채울 것. 도각(표제란) - `00_template_A1.json` 에 그림 자리 2개 추가 — 회사 로고는 용역회사 칸 왼쪽 (316~348 × 20~36mm), 설계자 서명은 설계 칸 아래(638~680 × 17.5~25.5mm). 자리·크기가 **템플릿 좌표로만** 정해지므로 도각을 고치면 그대로 따라옴. - `_fill_placeholders` 가 그림 자리(`imageData`)의 `{{키}}` 도 채움. 값을 못 구하면 빈 문자열을 남기지 않고 **엔티티째 제거** — 빈 값은 CAD 가 깨진 그림으로 그림. - `_transform_entity` 가 `points` 배열도 옮김. 그림(로고·서명)과 띠(Hatch)가 이 키를 쓰는데 여태 변환 대상이 아니라 도각을 옮기면 제자리에 남았음. DB (`011_title_block.sql`, 전부 ADD COLUMN·NULL 허용) - `projects` — `client_org`(시행청), `pm_user_id`·`field_lead_user_id`·`designer_user_id` - `companies.logo_path` · `users.signature_path` — 그림은 파일로 두고 경로만 담음 (기존 `storage_path`·`input_files` 와 같은 방식) - FK 는 걸지 않음 — 사유를 파일 머리말에 적음(소프트 삭제·LEFT JOIN·4환경 공유). 배선 - `_title_block_fields` 가 시행청·과업책임자·분야별책임자·설계자(배정 없으면 소유자)와 로고·서명 data URL 까지 실어 보냄. - `read_stored_asset()` 신설 — `storage/` 기준 상대 경로의 **파일**을 읽음. `resolve_stored_project_path()` 는 폴더를 만드는 프로젝트 루트용이라 파일에 못 씀. CAD 결함 1건 (이번 작업에서 드러남) - `screenCanvas.drawController.drawImage` 가 `(width, height)` 를 좌표처럼 변환해 화면 오프셋과 y 뒤집기가 섞여 들어갔음 — 그림이 제 자리를 벗어나고 비율이 무너짐. 크기는 배율만 곱하고 자리는 세계 중심으로 잡도록 고침(SVG 컨트롤러와 같은 방식). - 검증 창구 `__aisloCad.screen(x, y)` 추가 — 세계→화면 좌표. 그림·글자가 제 자리에 그려졌는지 픽셀로 판정할 때 씀. 검증: `pytest tmp/tests/ -q` 135 passed / 0 failed(그림 자리 3건 신규), `npx vitest run` 87 passed / 0 failed, `check-types`·`build` 통과. 화면 실측(5174, wdw): 표준도 API Text 24개 `{{` 잔존 0, Image 2개가 도각 좌표 (316,20)-(348,36)·(638,18)-(680,26)에 실림. 캔버스 픽셀 판정 — 두 자리 모두 배경색 외 픽셀이 그려짐(로고 88px·서명 64px), 로고 파랑(20,70,140)이 슬롯 안에서만 검출. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Engine_Cad_Cover.py | 3 +- .../B07_DesignDetail_Engine_Template.py | 41 +++++++++---- B07_DesignDetail/B07_DesignDetail_Router.py | 50 +++++++++++++--- .../screenCanvas.drawController.ts | 18 +++--- .../openwebcad/src/helpers/debug-hook.ts | 7 +++ common_util/common_util_storage.py | 25 ++++++++ db_management/011_title_block.sql | 29 ++++++++++ .../template_2dDrawing/00_template_A1.json | 58 ++++++++++++++++++- 8 files changed, 200 insertions(+), 31 deletions(-) create mode 100644 db_management/011_title_block.sql diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py index 1e7adad4..dbea164d 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Cover.py @@ -55,10 +55,9 @@ def build_cover_drawing(drawing_id: str, fields: dict[str, str] | None = None) - if isinstance(shape, dict): placed["shapeData"] = dict(shape) entities.append(placed) - _fill_placeholders(entities, fields or {}) return { "format": DRAWING_FORMAT, - "entities": entities, + "entities": _fill_placeholders(entities, fields or {}), "layers": [ _layer(NOTE_LAYER_ID, "주기"), _layer(FRAME_LAYER_ID, "도각", locked=True), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index df74288b..ff940da6 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -206,6 +206,14 @@ def _transform_entity( p = shape.get(key) if isinstance(p, dict): new_shape[key] = {"x": p["x"] * scale + dx, "y": p["y"] * scale + dy} + # 꼭짓점 배열을 쓰는 엔티티(Image 로고·서명, Hatch 띠)도 함께 옮긴다. + points = shape.get("points") + if isinstance(points, list): + new_shape["points"] = [ + {"x": point["x"] * scale + dx, "y": point["y"] * scale + dy} + for point in points + if isinstance(point, dict) + ] if "radius" in shape: new_shape["radius"] = shape["radius"] * scale options = shape.get("options") @@ -263,23 +271,37 @@ def usable_area() -> tuple[float, float]: _PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}") -def _fill_placeholders(entities: list[dict[str, Any]], fields: dict[str, str]) -> None: - """도각 텍스트의 {{키}}를 값으로 바꾼다. 값이 없으면 빈칸 — 남의 값이 남지 않는다. +def _fill_placeholders( + entities: list[dict[str, Any]], fields: dict[str, str] +) -> list[dict[str, Any]]: + """도각의 {{키}}를 값으로 바꾼 엔티티 목록을 낸다. 값이 없으면 빈칸 — 남의 값이 남지 않는다. 요청 문맥의 표제란 값(`use_title_fields`)이 바탕이고, 인자로 준 값(도면마다 다른 도면명 등)이 위에 얹힌다. 합치는 자리를 **치환 함수 한 곳**에 둬야 도각을 두르지 않는 표지처럼 다른 경로로 들어온 도면도 같은 값을 받는다(2026-09-02 표지 누락). """ fields = {**_title_fields.get(), **(fields or {})} + + def substitute(text: str) -> str: + return _PLACEHOLDER.sub(lambda match: str(fields.get(match.group(1), "")), text) + for entity in entities: - if entity.get("type") != "Text": - continue shape = entity.get("shapeData") or {} + # 글자 자리 label = shape.get("label") - if isinstance(label, str) and "{{" in label: - shape["label"] = _PLACEHOLDER.sub( - lambda match: str(fields.get(match.group(1), "")), label - ) + if entity.get("type") == "Text" and isinstance(label, str) and "{{" in label: + shape["label"] = substitute(label) + # 그림 자리(회사 로고·개인 서명) — 값은 data URL. 못 구하면 그림을 통째로 + # 빼서 빈 칸으로 둔다(빈 문자열을 남기면 CAD가 깨진 그림으로 그린다). + image = shape.get("imageData") + if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image: + shape["imageData"] = substitute(image) + # 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다. + return [ + entity + for entity in entities + if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData") + ] def frame_entities( @@ -323,5 +345,4 @@ def frame_entities( _transform_entity(entity, f"{drawing_id}:frame:{index}", scale, dx, dy) for index, entity in enumerate(template.get("entities", [])) ] - _fill_placeholders(placed, fields or {}) - return placed + return _fill_placeholders(placed, fields or {}) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index a47a1f18..d7a77523 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -3,7 +3,8 @@ import asyncio import logging import re -from pathlib import Path +from base64 import b64encode +from pathlib import Path, PurePosixPath from typing import Any from uuid import UUID @@ -54,7 +55,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( FrameTemplateSaveResponse, ) from common_util.common_util_drainage_context import load_drainage_context -from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path from common_util.common_util_workflow_state import complete_stage, start_stage from config.config_db import get_db_pool @@ -97,27 +98,50 @@ async def _company_dir(project_id: UUID) -> Path: return root.parent.parent +def _asset_data_url(relative_path: str | None) -> str: + """회사 로고·개인 서명 파일을 CAD `ImageEntity`가 읽는 data URL로 만든다. + + 파일이 없거나 경로가 수상하면 빈 문자열 — 그 자리는 그림째 빠진다 + (`_fill_placeholders`). 그림은 DB에 경로만 담는 기존 방식 그대로다. + """ + blob = read_stored_asset(relative_path) + if not blob: + return "" + suffix = PurePosixPath(str(relative_path)).suffix.lower() + mime = "image/svg+xml" if suffix == ".svg" else f"image/{suffix.lstrip('.') or 'png'}" + return f"data:{mime};base64,{b64encode(blob).decode('ascii')}" + + async def _title_block_fields(project_id: UUID) -> dict[str, str]: """도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다. 담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이 박혀 있어도 도면에는 나가지 않는다(2026-08-31 사용자 확정, 이것이 1순위 목적). + 사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는 + 배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게. + 아직 못 채우는 자리와 이유: - - 시행청·과업책임자·분야별책임자 — `projects`에 칸이 없다. B02 등록 화면과 - 마이그레이션이 서야 채워진다(공유 DB 변경이라 사용자 확정 대기). - 축척(A1/A3)·사업량·연도기번 — 값을 지어내지 않는다(임의 수치 금지). - 설계일자 — "확정일"인데 도각은 **확정 전**에 그려져 저장본에 굳는다. 채울 시점 정의가 미결이라 비워 둔다. + - 도면번호 — 단건 조회가 목록 순서를 모른다(목록을 다시 만들면 도면을 열 때마다 + 횡단 장 계획을 재계산하게 된다). """ pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( """ - SELECT p.name, p.region, c.name, u.name + SELECT p.name, p.region, p.client_org, c.name, c.logo_path, + COALESCE(designer.name, owner.name), + COALESCE(designer.signature_path, owner.signature_path), + pm.name, lead.name FROM projects p LEFT JOIN companies c ON c.id = p.company_id - LEFT JOIN users u ON u.id = p.user_id + LEFT JOIN users owner ON owner.id = p.user_id + LEFT JOIN users designer ON designer.id = p.designer_user_id + LEFT JOIN users pm ON pm.id = p.pm_user_id + LEFT JOIN users lead ON lead.id = p.field_lead_user_id WHERE p.id = %s AND p.deleted_at IS NULL """, (str(project_id),), @@ -125,8 +149,18 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: row = await cursor.fetchone() if not row: return {} - name, region, company_name, designer = row - fields = {"공사명": name, "위치": region, "용역회사": company_name, "설계자": designer} + name, region, client_org, company, logo_path, designer, signature_path, pm, lead = row + fields = { + "공사명": name, + "위치": region, + "시행청": client_org, + "용역회사": company, + "설계자": designer, + "과업책임자": pm, + "분야별책임자": lead, + "회사로고": _asset_data_url(logo_path), + "설계자서명": _asset_data_url(signature_path), + } return {key: str(value) for key, value in fields.items() if value} diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index f37b3199..efaa8a31 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -596,16 +596,14 @@ export class ScreenCanvasDrawController implements DrawController { angle: number ): void { if (this.batching) this.flushBatch(); - const [screenBasePoint, screenDimensions] = this.worldsToTargets([ - new Point(xMin, yMin), - new Point(width, height), - ]); - const screenXMin = screenBasePoint.x; - const screenYMin = screenBasePoint.y; - const screenWidth = screenDimensions.x; - const screenHeight = screenDimensions.y; - const screenCenterX = screenXMin + screenWidth / 2; - const screenCenterY = screenYMin + screenHeight / 2; + // 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과 + // y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02 + // 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다. + const screenWidth = width * this.screenScale; + const screenHeight = height * this.screenScale; + const screenCenter = this.worldToTarget(new Point(xMin + width / 2, yMin + height / 2)); + const screenCenterX = screenCenter.x; + const screenCenterY = screenCenter.y; // Rotate and translate context this.context.translate(screenCenterX, screenCenterY); diff --git a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts index 4fa3f95e..c54fbaf6 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/debug-hook.ts @@ -8,10 +8,12 @@ import { getHighlightedEntityIds, getLayers, getSelectedEntityIds, + getScreenCanvasDrawController, getSnapPoint, isDrawingDirty, isDrawingReadOnly, } from '../state'; +import { Point } from '@flatten-js/core'; import { isEntityHidden } from './visibility'; export function registerCadDebugHook(): void { @@ -45,5 +47,10 @@ export function registerCadDebugHook(): void { readOnly: () => isDrawingReadOnly(), dirty: () => isDrawingDirty(), meta: () => getDesignMeta(), + // 세계 좌표 → 화면 좌표. 그림·글자가 "제 자리에 그려졌나"를 픽셀로 판정할 때 쓴다. + screen: (x: number, y: number) => { + const point = getScreenCanvasDrawController().worldToTarget(new Point(x, y)); + return { x: point.x, y: point.y }; + }, }; } diff --git a/common_util/common_util_storage.py b/common_util/common_util_storage.py index 4afb11d3..59b65ffa 100644 --- a/common_util/common_util_storage.py +++ b/common_util/common_util_storage.py @@ -120,3 +120,28 @@ def resolve_stored_project_path(relative_path: str) -> str: os.makedirs(path, exist_ok=True) ensure_project_storage_layout(path) return path + + +def read_stored_asset(relative_path: str | None) -> bytes | None: + """`storage/` 기준 상대 경로의 **파일**을 읽는다. 없거나 수상하면 None. + + 회사 로고·개인 서명처럼 DB에 경로만 담아 두는 자산용이다. + `resolve_stored_project_path()`는 폴더를 만들어 주는 프로젝트 루트용이라 파일에는 + 쓸 수 없다. 검증 규칙(상대 경로·`storage/` 시작·루트 밖 금지)은 같다. + """ + if not relative_path: + return None + normalized = PurePosixPath(str(relative_path).replace("\\", "/")) + if normalized.is_absolute() or ".." in normalized.parts: + return None + if not normalized.parts or normalized.parts[0] != "storage": + return None + + storage_root = os.path.realpath(STORAGE_BASE_DIR) + path = os.path.realpath(os.path.join(storage_root, *normalized.parts[1:])) + if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root: + return None + if not os.path.isfile(path): + return None + with open(path, "rb") as file: + return file.read() diff --git a/db_management/011_title_block.sql b/db_management/011_title_block.sql new file mode 100644 index 00000000..1a0aeace --- /dev/null +++ b/db_management/011_title_block.sql @@ -0,0 +1,29 @@ +-- 011_title_block.sql +-- 도면 표제란(도각)에 채울 값 (2026-09-02 사용자 지시) +-- +-- 표제란 칸 중 DB에 자리가 없어 빈칸으로 나가던 것들을 만든다. +-- 시행청 · 과업책임자 · 분야별책임자 · 설계자 → projects +-- 회사 로고 → companies, 개인 서명 → users +-- 그림은 파일로 두고 **경로만** 담는다(기존 input_files·storage_path와 같은 결). +-- +-- 전부 ADD COLUMN(NULL 허용)이라 기존 행·기존 동작은 그대로다. 값이 없으면 표제란은 +-- 지금처럼 빈칸으로 나간다. +-- +-- 사람 배정 컬럼에 FK를 걸지 않은 이유: users는 소프트 삭제(deleted_at)라 행이 실제로 +-- 지워지는 일이 드물고, 조회가 LEFT JOIN이라 id가 떠 있어도 이름이 빈칸으로 나갈 뿐 +-- 도면이 깨지지 않는다("값이 없으면 빈칸" 규칙과 같은 결과). FK를 걸면 4환경이 공유하는 +-- DB에서 users 삭제·복구가 서로 막힌다. + +USE aislo_db; + +ALTER TABLE projects + ADD COLUMN IF NOT EXISTS client_org VARCHAR(255) NULL COMMENT '시행청(발주처)' AFTER road_type, + ADD COLUMN IF NOT EXISTS pm_user_id INT NULL COMMENT '과업책임자 (users.id)', + ADD COLUMN IF NOT EXISTS field_lead_user_id INT NULL COMMENT '분야별책임자 (users.id)', + ADD COLUMN IF NOT EXISTS designer_user_id INT NULL COMMENT '설계자 (users.id, 없으면 소유자)'; + +ALTER TABLE companies + ADD COLUMN IF NOT EXISTS logo_path VARCHAR(500) NULL COMMENT '회사 로고 파일 경로 (storage/ 기준 상대)'; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS signature_path VARCHAR(500) NULL COMMENT '개인 서명 파일 경로 (storage/ 기준 상대)'; diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index 30ffcf75..c3c7fe98 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1221,6 +1221,62 @@ "fontFamily": "sans-serif" } } + }, + { + "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 316.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 36.0 + }, + { + "x": 316.0, + "y": 36.0 + } + ], + "imageData": "{{회사로고}}" + } + }, + { + "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 638.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 25.5 + }, + { + "x": 638.0, + "y": 25.5 + } + ], + "imageData": "{{설계자서명}}" + } } ], "layers": [ @@ -1237,4 +1293,4 @@ "isLocked": false } ] -} +} \ No newline at end of file