feat(B07): 표제란에 로고·서명 자리 신설 + 시행청·담당자 DB 확장
사용자 지시(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) <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/ 기준 상대)';
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user