feat(b07): 도면 작도 영역 가이드 선 · 숫자 칸 공용 부품 · 끌기 · 도각 편집 영역 저장 (169-1·169-3·169-4)
- 그림칸 = 양식 drawing_area 그대로(여백 2 % 없앰) · 도각 편집이 영역 싣고 [완료] 때 저장 - 도면 응답 guide(작도 영역 도면 좌표) · M02 구조물 도각 응답 area - CAD 그리기 겹판 점선 사각형(저장 · DXF · 인쇄 제외) · 모서리 · 변 끌기(도각 편집 · M02 양식만) - 숫자 칸 공용 부품(왼쪽 · 위 · 폭 · 높이 mm) — M02 양식 · B07 도각 편집 · 끌기와 한 벌 - B08 구조물도 · M02 도면 카드 = 얹은 도각 영역 가이드(보기만) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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*\}\}")
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
},
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<unknown>) => {
|
||||
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<DrawingSaveRequestMessage>;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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<Guide> | 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
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>[];
|
||||
layer: Record<string, unknown> | null;
|
||||
/** 작도 영역(도각과 같은 좌표) — CAD 가이드 선(169-4 · 보기만) */
|
||||
area?: CadGuideArea | null;
|
||||
}
|
||||
const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false };
|
||||
const framePromises = new Map<string, Promise<StructureFrame | null>>();
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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": "도각(보기)",
|
||||
|
||||
@@ -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]}]
|
||||
@@ -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() {} }) },
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<D extends CadHostDrawing, M, Q> {
|
||||
@@ -107,6 +126,8 @@ export interface CadHost<D extends CadHostDrawing, M, Q> {
|
||||
placeTable: (entity: Record<string, unknown>, link: string) => void;
|
||||
/** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */
|
||||
updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
|
||||
/** 가이드 사각형만 갈기(숫자 칸 고침 · 169-3) — 도면은 다시 안 실음. */
|
||||
setGuideArea: (area: CadGuideArea) => void;
|
||||
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
|
||||
destroy: () => void;
|
||||
}
|
||||
@@ -266,6 +287,7 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
durationMs?: number;
|
||||
link?: string;
|
||||
count?: number;
|
||||
area?: CadGuideArea;
|
||||
};
|
||||
if (message.type === CAD_TOAST_MESSAGE) {
|
||||
const kind = (["info", "success", "warning", "error"] as const).find(
|
||||
@@ -299,6 +321,8 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
const [first, ...rest] = tableWaits.get(message.link) ?? [];
|
||||
tableWaits.set(message.link, rest);
|
||||
first?.(message.count ?? 0);
|
||||
} else if (message.type === CAD_GUIDE_CHANGED_MESSAGE && message.area) {
|
||||
options.onGuideChange?.(message.area);
|
||||
} else if (message.type === CAD_PLACE_CANCELLED_MESSAGE) {
|
||||
options.onPlaceCancel?.();
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
@@ -335,6 +359,7 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
requestSave,
|
||||
placeTable,
|
||||
updateTable,
|
||||
setGuideArea: (area) => postAfterLoad({ type: CAD_GUIDE_AREA_MESSAGE, area }),
|
||||
destroy: () => {
|
||||
stopLoadWatchdog();
|
||||
window.removeEventListener("message", onMessage);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<D extends CadHostDrawing> {
|
||||
@@ -30,6 +31,8 @@ export interface FrameTemplateEditor {
|
||||
banner: HTMLElement;
|
||||
/** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */
|
||||
isEditing: () => boolean;
|
||||
/** CAD 가이드 사각형을 끌었음(169-3) — 페이지가 `cad_host` `onGuideChange` 로 잇는다. */
|
||||
guideChanged: (area: CadGuideArea) => void;
|
||||
}
|
||||
|
||||
interface Options<D extends CadHostDrawing> {
|
||||
@@ -41,7 +44,10 @@ interface Options<D extends CadHostDrawing> {
|
||||
meta: null,
|
||||
frameEdit?: boolean,
|
||||
frameFields?: Record<string, string>,
|
||||
extra?: CadLoadExtra,
|
||||
) => void;
|
||||
/** 숫자 칸을 고친 영역을 CAD 가이드에 갈기(`cad_host` `setGuideArea`). */
|
||||
setGuideArea: (area: CadGuideArea) => void;
|
||||
/** CAD에서 현재 편집본을 받아온다. */
|
||||
requestCadDrawing: () => Promise<D>;
|
||||
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
|
||||
@@ -64,7 +70,18 @@ export function createFrameTemplateEditor<D extends CadHostDrawing>(
|
||||
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<D extends CadHostDrawing>(
|
||||
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<D extends CadHostDrawing>(
|
||||
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<D extends CadHostDrawing>(
|
||||
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<D extends CadHostDrawing>(
|
||||
}
|
||||
}
|
||||
|
||||
return { button, banner, isEditing: () => editing };
|
||||
return {
|
||||
button,
|
||||
banner,
|
||||
isEditing: () => editing,
|
||||
guideChanged: (area) => editing && areaFields.set(area),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user