fix(B07): 표제란 값 공급 통로 + CAD 회귀 6건 해소

표제란(도각) 값 공급
- `use_title_fields()` 문맥 변수 신설 — 회사 도각 폴더와 같은 방식이라 작도 엔진
  6개의 서명을 고치지 않음. `frame_entities` 가 문맥값 위에 호출부 값(도면명)을 얹음.
- 라우터가 도면 한 건을 그리기 전에 DB 값을 세움: 공사명·위치(projects),
  용역회사(companies), 설계자(users). 못 채운 자리는 빈칸 — 도각 원본의 남의 값이
  도면으로 나가지 않음.
- 미공급 항목과 사유를 `_title_block_fields` 주석에 명시: 시행청·과업책임자·
  분야별책임자는 DB 칸 부재(B02 마이그레이션 대기), 축척·사업량·연도기번은 임의 수치
  금지, 설계일자는 채울 시점 정의 미결.

CAD 회귀 6건 (87건 중 81 passed → 87 passed)
- `window` 부재 5건: `notifyWindow()` 한 곳으로 모으고 `window` 없는 Node 시험에서는
  통지를 건너뜀. `triggerReactUpdate` 가 이미 같은 이유로 시험을 건너뛰던 것과 같은 결.
- `find-closest-entity`: 목의 호가 90°까지만 돌아 클릭점에서 147px 떨어져 있었음 —
  시험 의도(호가 가장 가깝다)대로 3/4바퀴로 되돌림.
- 선색 기대값 4건: 앱 기본 선색이 흰 종이 배경에 맞춰 검정으로 바뀐 뒤 상류 기대값
  `#fff` 가 남아 있었음 — `getActiveLineColor()` 대조로 교체.
- 좌표 NaN: 없어진 `TOOLBAR_WIDTH` 를 시험 보조가 아직 참조 — 입력 컨트롤러가 캔버스
  bounding rect 를 쓰도록 바뀐 현행에 맞춤.
- 호 각도 비교: 같은 각의 음수·2π 표기 차이라 [0, 2π) 정규화 후 대조.

검증: `npx vitest run` 87 passed / 0 failed, `npm run check-types`·`npm run build` 통과,
`pytest tmp/tests/ -q` 131 passed / 0 failed(신규 `test_b07_title_block_fields.py` 4건 포함).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 08:00:39 +09:00
co-authored by Claude Opus 5
parent 4cb9b15939
commit 793e1aad37
14 changed files with 93 additions and 34 deletions
@@ -52,6 +52,16 @@ def use_company_templates(company_dir: Path | None) -> None:
_company_dir.set(company_dir)
# 이 요청이 도각 표제란에 채울 값. 회사 도각 폴더와 같은 이유로 문맥 변수다 —
# 엔진 6개의 서명을 줄줄이 고치지 않는다. 값을 못 구한 자리는 **빈칸**으로 남는다.
_title_fields: ContextVar[dict[str, str]] = ContextVar("b07_title_fields", default={})
def use_title_fields(fields: dict[str, str] | None) -> None:
"""이 요청이 도각 표제란에 채울 값을 정한다. None이면 표제란이 전부 빈칸이다."""
_title_fields.set(fields or {})
def company_template_path(company_dir: Path, name: str = A1_TEMPLATE) -> Path:
"""회사 도각 파일 경로(없을 수도 있다)."""
return Path(company_dir) / COMPANY_TEMPLATE_SUBDIR / f"{name}.json"
@@ -307,5 +317,6 @@ 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 {})
# 요청 문맥의 값이 바탕, 호출부가 준 값(도면명 등 도면마다 다른 것)이 위에 얹힌다.
_fill_placeholders(placed, {**_title_fields.get(), **(fields or {})})
return placed
@@ -30,6 +30,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
frame_template_document,
save_company_template,
use_company_templates,
use_title_fields,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
MASS_HAUL_ID,
@@ -96,6 +97,39 @@ async def _company_dir(project_id: UUID) -> Path:
return root.parent.parent
async def _title_block_fields(project_id: UUID) -> dict[str, str]:
"""도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다.
담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이
박혀 있어도 도면에는 나가지 않는다(2026-08-31 사용자 확정, 이것이 1순위 목적).
아직 못 채우는 자리와 이유:
- 시행청·과업책임자·분야별책임자 — `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
FROM projects p
LEFT JOIN companies c ON c.id = p.company_id
LEFT JOIN users u ON u.id = p.user_id
WHERE p.id = %s AND p.deleted_at IS NULL
""",
(str(project_id),),
)
row = await cursor.fetchone()
if not row:
return {}
name, region, company_name, designer = row
fields = {"공사명": name, "위치": region, "용역회사": company_name, "설계자": designer}
return {key: str(value) for key, value in fields.items() if value}
async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
"""노선 전체의 측점별 설계 지정 {측점(m): design}. 장 배치·목록이 함께 쓴다."""
pool = get_db_pool()
@@ -142,6 +176,8 @@ async def get_design_drawing(
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
use_company_templates(project_root.parent.parent)
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
use_title_fields(await _title_block_fields(project_id))
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
design: dict[str, Any] | None = None
source_design: Any = None
@@ -11,7 +11,7 @@ import { HtmlEvent } from '../App.types';
import type { Entity, JsonEntity } from '../entities/Entity';
import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities';
import { getEntitiesAndLayersFromJsonObject } from '../helpers/import-export-handlers/import-entities-from-json';
import { getActiveLayerId } from '../state';
import { getActiveLayerId, notifyWindow } from '../state';
const STORAGE_KEY = 'aislo-cad-block-library';
@@ -24,7 +24,7 @@ export interface BlockDefinition {
let blocks: BlockDefinition[] | null = null;
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
function load(): BlockDefinition[] {
if (blocks) return blocks;
@@ -2,7 +2,7 @@
import { toast } from 'react-toastify';
import { Actor } from 'xstate';
import { HtmlEvent } from '../App.types';
import { getSelectedEntities, isDrawingReadOnly, setActiveToolActor } from '../state';
import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state';
import type { CadCommand } from './command.types';
import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry';
@@ -19,7 +19,7 @@ function log(line: string) {
if (commandHistory.length > COMMAND_HISTORY_LIMIT) {
commandHistory.splice(0, commandHistory.length - COMMAND_HISTORY_LIMIT);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
}
/** 명령 한 건 실행. 도구형이면 도구를 활성화하고, 즉시형이면 run()을 부른다. */
@@ -3,6 +3,7 @@
* 리액트 컴포넌트 바깥에 둔다. 값이 바뀌면 UPDATE_STATE로 다시 그린다.
*/
import { HtmlEvent } from '../App.types';
import { notifyWindow } from '../state';
export type InspectorTab = 'properties' | 'layers';
@@ -12,7 +13,7 @@ let quickPropertiesVisible = false;
let activeRibbonTab = 'home';
let blockLibraryVisible = false;
const notify = () => window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
const notify = () => notifyWindow(HtmlEvent.UPDATE_STATE);
export const getInspectorTab = () => inspectorTab;
export const isInspectorCollapsed = () => inspectorCollapsed;
@@ -33,8 +33,9 @@ export const arcAndLineEntitiesMock: JsonDrawingFileSerialized = {
},
radius: 156.92367603040066,
startAngle: 0,
// endAngle: (2 * Math.PI * 3) / 4,
endAngle: 1.5707963267948966,
// 클릭점(393,1108)이 호 위(중심각 ≈145°)에 놓이도록 3/4바퀴를 쓴다 —
// 90°까지만 돌면 호가 클릭점에서 147px 떨어져 직선(64px)이 더 가깝다.
endAngle: (2 * Math.PI * 3) / 4,
counterClockwise: true,
},
},
@@ -4,6 +4,7 @@
*/
import { HtmlEvent } from '../App.types';
import type { Entity } from '../entities/Entity';
import { notifyWindow } from '../state';
import { bumpSceneVersion } from './scene-version';
let hiddenEntityIds = new Set<string>();
@@ -14,7 +15,7 @@ export const getHiddenEntityCount = (): number => hiddenEntityIds.size;
function apply(ids: Set<string>): void {
hiddenEntityIds = ids;
bumpSceneVersion();
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
}
/** 지정 객체를 숨긴다 */
+15 -9
View File
@@ -309,9 +309,15 @@ export const setActiveToolActor = (
triggerReactUpdate(StateVariable.activeTool);
}
};
/** 화면에 알린다 — `window`가 없는 Node 시험에서는 통지를 건너뛴다.
* (`triggerReactUpdate`가 이미 같은 이유로 시험 환경을 건너뛴다.) */
export const notifyWindow = (event: HtmlEvent) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(event));
};
export const setLastStateInstructions = (newInstructions: string | null) => {
lastStateInstructions = newInstructions;
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setEntities = (newEntities: Entity[], trackInUndoStack = false) => {
if (trackInUndoStack) {
@@ -321,7 +327,7 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
bumpSceneVersion();
if (trackInUndoStack) {
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
};
/** 도면을 새로 실었거나 저장했다 — 미저장 표시를 내린다. */
@@ -348,7 +354,7 @@ export const setSelectedEntityIds = (newEntityIds: string[]) => {
selectedEntityIds = newEntityIds;
selectedEntityIdSet = new Set(newEntityIds);
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setShouldDrawCursor = (newValue: boolean) => {
shouldDrawCursor = newValue;
@@ -462,19 +468,19 @@ export const setSnapEnabled = (enabled: boolean) => {
setHoveredSnapPoints([]);
setAngleGuideEntities([]);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setSnapTrackingEnabled = (enabled: boolean) => {
snapTrackingEnabled = enabled;
if (!enabled) {
setHoveredSnapPoints([]);
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setGridEnabled = (enabled: boolean) => {
gridEnabled = enabled;
bumpSceneVersion();
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
};
export const setDesignMeta = (newMeta: DesignMeta | null) => {
designMeta = newMeta;
@@ -556,7 +562,7 @@ export function undo() {
updateStates(undoState);
drawingDirty = true; // 되돌려도 저장본과는 다를 수 있다 — 미저장 경고 대상이다
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
export function redo() {
@@ -565,7 +571,7 @@ export function redo() {
updateStates(redoState);
drawingDirty = true;
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
notifyWindow(HtmlEvent.DRAWING_CHANGED);
}
export function triggerReactUpdate(variable: StateVariable) {
@@ -577,5 +583,5 @@ export function triggerReactUpdate(variable: StateVariable) {
return;
}
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
notifyWindow(HtmlEvent.UPDATE_STATE);
}
@@ -3,7 +3,7 @@
* Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework
*/
import { expect, test } from 'vitest';
import { getEntities } from '../../../src/state';
import { getActiveLineColor, getEntities } from '../../../src/state';
import { EntityName, type JsonEntity } from '../../../src/entities/Entity';
import { initApplication } from '../../helpers/init-application';
import { CANVAS_HEIGHT } from '../../helpers/tests.consts';
@@ -24,7 +24,7 @@ test('Draw circle', async () => {
expect(circleEntity.getType()).toBe(EntityName.Circle);
const circleJson = (await circleEntity.toJson()) as JsonEntity<CircleJsonData>;
expect(circleJson.lineColor).toBe('#fff');
expect(circleJson.lineColor).toBe(getActiveLineColor());
expect(circleJson.lineWidth).toBe(1);
expect(circleJson.type).toBe('Circle');
expect(circleJson.shapeData.center.x).toBe(325);
@@ -3,7 +3,7 @@
* Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework
*/
import { expect, test } from 'vitest';
import { getEntities } from '../../../src/state';
import { getActiveLineColor, getEntities } from '../../../src/state';
import { Tool } from '../../../src/tools';
import { EntityName, type JsonEntity } from '../../../src/entities/Entity';
import { initApplication } from '../../helpers/init-application';
@@ -23,7 +23,7 @@ test('Draw line', async () => {
expect(lineEntity.getType()).toBe(EntityName.Line);
const lineJson = (await lineEntity.toJson()) as JsonEntity<LineJsonData>;
expect(lineJson.lineColor).toBe('#fff');
expect(lineJson.lineColor).toBe(getActiveLineColor());
expect(lineJson.lineWidth).toBe(1);
expect(lineJson.type).toBe('Line');
expect(lineJson.shapeData.startPoint.x).toBe(185);
@@ -5,7 +5,7 @@
import { expect, test } from 'vitest';
import { EntityName, type JsonEntity } from '../../../src/entities/Entity';
import type { RectangleJsonData } from '../../../src/entities/RectangleEntity';
import { getEntities } from '../../../src/state';
import { getActiveLineColor, getEntities } from '../../../src/state';
import { Tool } from '../../../src/tools';
import { click } from '../../helpers/click';
import { initApplication } from '../../helpers/init-application';
@@ -23,7 +23,7 @@ test('Draw circle', async () => {
expect(rectangleEntity.getType()).toBe(EntityName.Rectangle);
const rectangleJson = (await rectangleEntity.toJson()) as JsonEntity<RectangleJsonData>;
expect(rectangleJson.lineColor).toBe('#fff');
expect(rectangleJson.lineColor).toBe(getActiveLineColor());
expect(rectangleJson.lineWidth).toBe(1);
expect(rectangleJson.type).toBe('Rectangle');
expect(rectangleJson.shapeData.points[0].x).toBe(185);
@@ -1,4 +1,3 @@
import { TOOLBAR_WIDTH } from '../../src/App.consts';
import { MouseButton } from '../../src/App.types';
import type { InputController } from '../../src/inputController/input-controller';
@@ -17,7 +16,8 @@ export function click(
) {
inputController.handleMouseUp({
button: mouseButton,
clientX: TOOLBAR_WIDTH + x, // Coordinates are relative to the top left of the draw area excluding the toolbar
// 입력 컨트롤러가 캔버스 bounding rect(시험에서는 0)를 빼므로 화면 좌표를 그대로 준다.
clientX: x,
clientY: y,
preventDefault: () => {},
stopPropagation: () => {},
@@ -1,4 +1,4 @@
import { TOOLBAR_WIDTH } from '../../src/App.consts';
export const CANVAS_WIDTH = 1920 - TOOLBAR_WIDTH;
// 좌표 변환이 캔버스 bounding rect 를 쓰도록 바뀌어 툴바 폭 상수가 없어졌다 —
// 시험 캔버스는 화면 전체 폭을 그대로 쓴다.
export const CANVAS_WIDTH = 1920;
export const CANVAS_HEIGHT = 1080;
@@ -4,7 +4,7 @@ import type { ArcJsonData } from '../../../src/entities/ArcEntity';
import { EntityName, type JsonEntity } from '../../../src/entities/Entity';
import type { LineJsonData } from '../../../src/entities/LineEntity';
import { pointDistance } from '../../../src/helpers/distance-between-points';
import { getEntities } from '../../../src/state';
import { getActiveLineColor, getEntities } from '../../../src/state';
import { initApplication } from '../../helpers/init-application';
import { replayRecording } from '../../helpers/replay-recording';
import { CANVAS_HEIGHT } from '../../helpers/tests.consts';
@@ -23,7 +23,7 @@ test('Draw circle and line and erase part of circle', async () => {
expect(lineEntity.getType()).toBe(EntityName.Line);
const lineJson = (await lineEntity.toJson()) as JsonEntity<LineJsonData>;
expect(lineJson.lineColor).toBe('#fff');
expect(lineJson.lineColor).toBe(getActiveLineColor());
expect(lineJson.lineWidth).toBe(1);
expect(lineJson.type).toBe('Line');
expect(lineJson.shapeData.startPoint.x).toBe(473);
@@ -35,7 +35,7 @@ test('Draw circle and line and erase part of circle', async () => {
expect(arcEntity.getType()).toBe(EntityName.Arc);
const arcJson = (await arcEntity.toJson()) as JsonEntity<ArcJsonData>;
expect(arcJson.lineColor).toBe('#fff');
expect(arcJson.lineColor).toBe(getActiveLineColor());
expect(arcJson.lineWidth).toBe(1);
expect(arcJson.type).toBe('Arc');
expect(arcJson.shapeData.center.x).toBe(266);
@@ -45,6 +45,9 @@ test('Draw circle and line and erase part of circle', async () => {
new Point(542, CANVAS_HEIGHT - 441)
);
expect(arcJson.shapeData.radius).toBeCloseTo(radius, 5);
expect(arcJson.shapeData.startAngle).toBeCloseTo(0.7338182524767606, 5);
expect(arcJson.shapeData.endAngle).toBeCloseTo(-0.7338182524767606, 5);
// 각은 [0, 2π)로 정규화해 비교한다 — 같은 각을 음수로도 2π 더한 값으로도 쓸 수 있다.
const turn = 2 * Math.PI;
const normalize = (angle: number) => ((angle % turn) + turn) % turn;
expect(normalize(arcJson.shapeData.startAngle)).toBeCloseTo(normalize(0.7338182524767606), 5);
expect(normalize(arcJson.shapeData.endAngle)).toBeCloseTo(normalize(-0.7338182524767606), 5);
});