feat(B07): 자리표 칸 크기 조절·그림 비율 유지·편집 중 실제 값 미리보기
- 텍스트 자리표에 칸 크기(boxWidth/boxHeight) 도입 — basePoint 를 칸 중심으로 두고 가로·세로 가운데 정렬, 선택 시 칸 테두리 표시, DXF 내보내기도 중앙 정렬로 반영
- 그림은 칸 안에서 비율을 유지하며 맞춤(letterbox) — 칸을 늘려도 로고·서명이 찌그러지지 않음
- 자리표 패널에 선택 항목 칸 크기(가로·세로 mm) 입력 추가
- 도각 편집 중 자리표에 실제 값 미리보기 — 서버가 표제란 값(공사명·회사·담당자·로고·서명)을 함께 내려주고 화면만 값으로 표시, 저장값은 {{키}} 토큰 유지
- title_block_fields 공개화 및 frame-template 응답에 fields 추가
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,8 @@ export interface FrameTemplateResponse {
|
||||
drawing: CadDrawing;
|
||||
/** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */
|
||||
customized: boolean;
|
||||
/** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */
|
||||
fields?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
|
||||
|
||||
@@ -20,6 +20,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.enums import TextEntityAlignment
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import bundled_tool
|
||||
|
||||
@@ -77,7 +78,11 @@ def _add_entity(
|
||||
shape["label"],
|
||||
dxfattribs={**attribs, "height": height, "rotation": rotation},
|
||||
)
|
||||
text.set_placement(base)
|
||||
# 자리표는 칸 한가운데에 선다 — 내보낸 파일에서도 같은 자리에 오게 가운데 맞춤.
|
||||
if options.get("boxWidth") and options.get("boxHeight"):
|
||||
text.set_placement(base, align=TextEntityAlignment.MIDDLE_CENTER)
|
||||
else:
|
||||
text.set_placement(base)
|
||||
elif point := _xy(shape.get("point")):
|
||||
space.add_point(point, dxfattribs=attribs)
|
||||
elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)):
|
||||
|
||||
@@ -114,7 +114,7 @@ def _asset_data_url(relative_path: str | None) -> str:
|
||||
return f"data:{mime};base64,{b64encode(blob).decode('ascii')}"
|
||||
|
||||
|
||||
async def _title_block_fields(project_id: UUID) -> dict[str, str]:
|
||||
async def title_block_fields(project_id: UUID) -> dict[str, str]:
|
||||
"""도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다.
|
||||
|
||||
담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이
|
||||
@@ -275,7 +275,7 @@ async def get_design_drawing(
|
||||
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
|
||||
use_company_templates(project_root.parent.parent)
|
||||
# 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다.
|
||||
use_title_fields(await _title_block_fields(project_id))
|
||||
use_title_fields(await title_block_fields(project_id))
|
||||
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
|
||||
design: dict[str, Any] | None = None
|
||||
source_design: Any = None
|
||||
|
||||
@@ -26,6 +26,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
use_company_templates,
|
||||
validate_template_entities,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
DrawingExportRequest,
|
||||
FrameTemplateImportResponse,
|
||||
@@ -56,10 +57,14 @@ async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONRe
|
||||
try:
|
||||
company_dir = await _company_dir(project_id)
|
||||
use_company_templates(company_dir)
|
||||
# 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는
|
||||
# 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다).
|
||||
fields = await title_block_fields(project_id)
|
||||
return FrameTemplateResponse(
|
||||
project_id=str(project_id),
|
||||
drawing=frame_template_document(),
|
||||
customized=company_template_path(company_dir).is_file(),
|
||||
fields=fields,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
|
||||
@@ -102,6 +102,9 @@ class FrameTemplateResponse(BaseModel):
|
||||
drawing: dict[str, Any]
|
||||
# 회사가 고친 도각을 쓰고 있으면 True, 프로그램 기본 도각이면 False.
|
||||
customized: bool = False
|
||||
# 자리표에 **보여 줄** 실제 값 (2026-09-06 사용자 지시) — 편집 화면 전용이고
|
||||
# 저장값은 `{{키}}` 토큰 그대로다. 값이 없는 자리는 담기지 않는다.
|
||||
fields: dict[str, str] = {}
|
||||
|
||||
|
||||
class FrameTemplateImportResponse(BaseModel):
|
||||
|
||||
@@ -30,7 +30,12 @@ interface Options {
|
||||
projectId: string;
|
||||
/** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다).
|
||||
* frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */
|
||||
sendLoad: (drawing: CadDrawing, meta: null, frameEdit?: boolean) => void;
|
||||
sendLoad: (
|
||||
drawing: CadDrawing,
|
||||
meta: null,
|
||||
frameEdit?: boolean,
|
||||
frameFields?: Record<string, string>,
|
||||
) => void;
|
||||
/** CAD에서 현재 편집본을 받아온다. */
|
||||
requestCadDrawing: () => Promise<CadDrawing>;
|
||||
/** 편집을 마친 뒤 보던 도면으로 돌아간다. */
|
||||
@@ -41,6 +46,8 @@ interface Options {
|
||||
|
||||
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
|
||||
let editing = false;
|
||||
// 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다.
|
||||
let frameFields: Record<string, string> = {};
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "b07-frame-edit";
|
||||
@@ -110,7 +117,7 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
importButton.disabled = true;
|
||||
try {
|
||||
const response = await importFrameTemplate(options.projectId, file);
|
||||
options.sendLoad(response.drawing, null, true);
|
||||
options.sendLoad(response.drawing, null, true, frameFields);
|
||||
label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`;
|
||||
showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success");
|
||||
} catch (error) {
|
||||
@@ -126,13 +133,14 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor
|
||||
async function enter(): Promise<void> {
|
||||
try {
|
||||
const response = await fetchFrameTemplate(options.projectId);
|
||||
frameFields = response.fields ?? {};
|
||||
editing = true;
|
||||
button.disabled = true;
|
||||
banner.hidden = false;
|
||||
label.textContent = response.customized
|
||||
? "도각 편집 중 — 회사 도각을 고치고 있습니다."
|
||||
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
|
||||
options.sendLoad(response.drawing, null, true);
|
||||
options.sendLoad(response.drawing, null, true, frameFields);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
|
||||
}
|
||||
|
||||
@@ -126,7 +126,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadHost.append(frame, license);
|
||||
|
||||
let cadReady = false;
|
||||
let pendingLoad: { drawing: CadDrawing; meta: DesignMeta | null; frameEdit: boolean } | undefined;
|
||||
let pendingLoad:
|
||||
| {
|
||||
drawing: CadDrawing;
|
||||
meta: DesignMeta | null;
|
||||
frameEdit: boolean;
|
||||
frameFields: Record<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
let currentDrawing: DesignDrawingItem | undefined;
|
||||
let currentIndex = -1;
|
||||
let currentConfirmed = false;
|
||||
@@ -209,11 +216,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
|
||||
// frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다
|
||||
// (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김).
|
||||
const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null, frameEdit = false) => {
|
||||
pendingLoad = { drawing, meta, frameEdit };
|
||||
const sendLoad = (
|
||||
drawing: CadDrawing,
|
||||
meta: DesignMeta | null,
|
||||
frameEdit = false,
|
||||
frameFields: Record<string, string> = {},
|
||||
) => {
|
||||
pendingLoad = { drawing, meta, frameEdit, frameFields };
|
||||
if (!cadReady) return;
|
||||
frame.contentWindow?.postMessage(
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit },
|
||||
{ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields },
|
||||
window.location.origin,
|
||||
);
|
||||
pendingLoad = undefined;
|
||||
@@ -479,7 +491,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
} else if (message.type === CAD_READY_MESSAGE) {
|
||||
cadReady = true;
|
||||
if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta, pendingLoad.frameEdit);
|
||||
if (pendingLoad)
|
||||
sendLoad(
|
||||
pendingLoad.drawing,
|
||||
pendingLoad.meta,
|
||||
pendingLoad.frameEdit,
|
||||
pendingLoad.frameFields,
|
||||
);
|
||||
} else if (message.type === CAD_LOADED_MESSAGE) {
|
||||
cadHost.dataset.loading = "false";
|
||||
} else if (message.type === CAD_ERROR_MESSAGE) {
|
||||
|
||||
@@ -997,3 +997,22 @@ body > canvas[data-id="canvas"] {
|
||||
.cad-frame-tokens__button:hover {
|
||||
background: var(--cad-accent-soft, var(--cad-chrome-raised));
|
||||
}
|
||||
|
||||
/* 자리표 칸 크기 입력 (2026-09-06) */
|
||||
.cad-frame-tokens__size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--cad-text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cad-frame-tokens__size input {
|
||||
width: 64px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--cad-line);
|
||||
border-radius: 4px;
|
||||
background: var(--cad-chrome);
|
||||
color: var(--cad-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { TextEntity } from '../entities/TextEntity';
|
||||
import {
|
||||
getActiveLayerId,
|
||||
getEntities,
|
||||
getFrameFields,
|
||||
getScreenCanvasDrawController,
|
||||
getSelectedEntities,
|
||||
isFrameEditMode,
|
||||
setEntities,
|
||||
} from '../state';
|
||||
@@ -43,6 +45,9 @@ const TEXT_TOKENS = [
|
||||
const IMAGE_TOKENS = ['회사로고', '과업책임자서명', '분야별책임자서명', '설계자서명'] as const;
|
||||
|
||||
const TEXT_SIZE_MM = 5;
|
||||
// 자리표가 차지하는 칸 기본 크기(mm). 놓은 뒤 아래 「칸 크기」에서 고친다.
|
||||
const TEXT_BOX_WIDTH_MM = 60;
|
||||
const TEXT_BOX_HEIGHT_MM = 10;
|
||||
const IMAGE_WIDTH_MM = 32;
|
||||
const IMAGE_HEIGHT_MM = 16;
|
||||
|
||||
@@ -58,7 +63,11 @@ function addTextPlaceholder(token: string): void {
|
||||
const entity = new TextEntity(getActiveLayerId(), `{{${token}}}`, center, {
|
||||
fontSize: TEXT_SIZE_MM,
|
||||
textAlign: 'center',
|
||||
boxWidth: TEXT_BOX_WIDTH_MM,
|
||||
boxHeight: TEXT_BOX_HEIGHT_MM,
|
||||
});
|
||||
// 편집 중에는 실제 값을 보여 준다 — 저장값은 토큰 그대로다.
|
||||
entity.previewLabel = getFrameFields()[token] ?? null;
|
||||
setEntities([...getEntities(), entity], true);
|
||||
}
|
||||
|
||||
@@ -82,18 +91,49 @@ async function addImagePlaceholder(token: string): Promise<void> {
|
||||
layerId: getActiveLayerId(),
|
||||
shapeData: { points, imageData: `{{${token}}}` },
|
||||
} as Parameters<typeof ImageEntity.fromJson>[0]);
|
||||
const preview = getFrameFields()[token];
|
||||
if (preview) entity.setPreviewImage(preview);
|
||||
setEntities([...getEntities(), entity], true);
|
||||
}
|
||||
|
||||
/** 지금 고른 자리표 하나 — 칸 크기를 고칠 대상. 없으면 null. */
|
||||
function selectedPlaceholder(): TextEntity | ImageEntity | null {
|
||||
const selected = getSelectedEntities();
|
||||
if (selected.length !== 1) return null;
|
||||
const entity = selected[0];
|
||||
if (entity instanceof TextEntity && entity.getLabel().includes('{{')) return entity;
|
||||
if (entity instanceof ImageEntity && entity.isPlaceholder()) return entity;
|
||||
return null;
|
||||
}
|
||||
|
||||
function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: number } {
|
||||
const box = entity.getBoundingBox();
|
||||
return { width: Math.round(box.width * 10) / 10, height: Math.round(box.height * 10) / 10 };
|
||||
}
|
||||
|
||||
export const FramePlaceholderPanel: FC = () => {
|
||||
const [visible, setVisible] = useState(isFrameEditMode());
|
||||
const [picked, setPicked] = useState<TextEntity | ImageEntity | null>(null);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const refresh = useCallback(() => setVisible(isFrameEditMode()), []);
|
||||
const refresh = useCallback(() => {
|
||||
setVisible(isFrameEditMode());
|
||||
const entity = selectedPlaceholder();
|
||||
setPicked(entity);
|
||||
if (entity) setSize(boxSizeOf(entity));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
window.addEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh);
|
||||
}, [refresh]);
|
||||
|
||||
const applySize = (width: number, height: number): void => {
|
||||
if (!picked) return;
|
||||
setSize({ width, height });
|
||||
picked.setBoxSize(width, height);
|
||||
setEntities([...getEntities()], true);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
@@ -115,7 +155,7 @@ export const FramePlaceholderPanel: FC = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="cad-frame-tokens__group">
|
||||
<span className="cad-frame-tokens__title">그림</span>
|
||||
<span className="cad-frame-tokens__title">그림 (칸에 비율 그대로 들어감)</span>
|
||||
{IMAGE_TOKENS.map((token) => (
|
||||
<button
|
||||
key={token}
|
||||
@@ -127,6 +167,29 @@ export const FramePlaceholderPanel: FC = () => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{picked && (
|
||||
<div className="cad-frame-tokens__group">
|
||||
<span className="cad-frame-tokens__title">고른 자리표 칸 크기 (mm)</span>
|
||||
<label className="cad-frame-tokens__size">
|
||||
가로
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={size.width}
|
||||
onChange={(event) => applySize(Number(event.target.value), size.height)}
|
||||
/>
|
||||
</label>
|
||||
<label className="cad-frame-tokens__size">
|
||||
세로
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={size.height}
|
||||
onChange={(event) => applySize(size.width, Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,6 +33,22 @@ export class ImageEntity implements Entity {
|
||||
* 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다.
|
||||
*/
|
||||
private sourceData: string | null = null;
|
||||
/** 저장값(그림 주소 또는 자리표 토큰). */
|
||||
public getSourceData(): string | null {
|
||||
return this.sourceData;
|
||||
}
|
||||
|
||||
/** 자리표인가 — `{{회사로고}}` 처럼 토큰을 들고 있는 그림. */
|
||||
public isPlaceholder(): boolean {
|
||||
return (this.sourceData ?? '').includes('{{');
|
||||
}
|
||||
|
||||
/** 도각 편집에서만 쓰는 보여 주기용 그림. 저장값(sourceData)은 토큰 그대로 둔다. */
|
||||
public setPreviewImage(dataUrl: string): void {
|
||||
const image = new Image();
|
||||
image.src = dataUrl;
|
||||
this.imageElement = image;
|
||||
}
|
||||
|
||||
constructor(
|
||||
layerId: string,
|
||||
@@ -72,7 +88,7 @@ export class ImageEntity implements Entity {
|
||||
// 자리표(`{{회사로고}}` 등)는 그림이 없어 화면에 아무것도 안 보였다 — 도각 편집에서
|
||||
// 무엇을 어디에 놓았는지 알 수 없어, 자리표일 때는 테두리와 이름을 늘 그린다
|
||||
// (2026-09-06). 출력 때는 서버가 값으로 바꾸거나 엔티티째 빼므로 산출물에 안 실린다.
|
||||
const placeholder = (this.sourceData ?? '').includes('{{');
|
||||
const placeholder = this.isPlaceholder() && !this.imageElement.src;
|
||||
// 그 밖의 그림은 **집었을 때만** 테두리를 그린다. 늘 그리면 도각의 로고 자리에 흰
|
||||
// 사각형이 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02).
|
||||
if (highlighted || selected || placeholder) {
|
||||
@@ -82,6 +98,7 @@ export class ImageEntity implements Entity {
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
// 아직 보여 줄 그림이 없으면 이름표만 남긴다.
|
||||
drawController.drawText(this.sourceData ?? '', this.polygon.box.center, {
|
||||
...DEFAULT_TEXT_OPTIONS,
|
||||
textAlign: 'center',
|
||||
@@ -91,20 +108,38 @@ export class ImageEntity implements Entity {
|
||||
return; // 그림이 없으니 그릴 것도 없다
|
||||
}
|
||||
|
||||
const width = this.polygon.box.width;
|
||||
const height = this.polygon.box.height;
|
||||
// 칸 안에 **비율을 지켜** 넣는다 (2026-09-06 사용자 지시) — 칸을 늘렸다고 그림이
|
||||
// 늘어나면 로고·서명이 찌그러진다. 남는 자리는 비운다(가운데 맞춤).
|
||||
const boxWidth = this.polygon.box.width;
|
||||
const boxHeight = this.polygon.box.height;
|
||||
const naturalWidth = this.imageElement.naturalWidth || boxWidth;
|
||||
const naturalHeight = this.imageElement.naturalHeight || boxHeight;
|
||||
const fit = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight);
|
||||
const width = naturalWidth * fit;
|
||||
const height = naturalHeight * fit;
|
||||
|
||||
// Draw image
|
||||
drawController.drawImage(
|
||||
this.imageElement,
|
||||
this.polygon.box.xmin,
|
||||
this.polygon.box.ymin,
|
||||
this.polygon.box.xmin + (boxWidth - width) / 2,
|
||||
this.polygon.box.ymin + (boxHeight - height) / 2,
|
||||
width,
|
||||
height,
|
||||
this.angle
|
||||
);
|
||||
}
|
||||
|
||||
/** 자리표 칸 크기(mm)를 바꾼다. 가운데는 그대로 두고 네 귀만 다시 잡는다. */
|
||||
public setBoxSize(width: number, height: number): void {
|
||||
const center = this.polygon.box.center;
|
||||
const halfWidth = Math.max(width, 1) / 2;
|
||||
const halfHeight = Math.max(height, 1) / 2;
|
||||
this.polygon = twoPointBoxToPolygon(
|
||||
new Point(center.x - halfWidth, center.y - halfHeight),
|
||||
new Point(center.x + halfWidth, center.y + halfHeight)
|
||||
);
|
||||
}
|
||||
|
||||
public move(x: number, y: number) {
|
||||
this.polygon = this.polygon.translate(new Vector(x, y));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface TextOptions {
|
||||
/** 굵게·기울임 (문자 편집기 기본 서식). 밑줄은 캔버스에 없어 넣지 않았다 */
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
/**
|
||||
* 도각 자리표의 칸 크기(mm). 있으면 `basePoint` 가 **칸의 한가운데**이고 글자는
|
||||
* 가로·세로 가운데 맞춤으로 그려진다 (2026-09-06 사용자 지시). 없으면 예전처럼
|
||||
* 글자 하나로만 산다.
|
||||
*/
|
||||
boxWidth?: number;
|
||||
boxHeight?: number;
|
||||
}
|
||||
|
||||
export class TextEntity implements Entity {
|
||||
@@ -32,6 +39,12 @@ export class TextEntity implements Entity {
|
||||
public opacity?: number;
|
||||
/** GROUP으로 묶인 객체가 공유하는 식별자 */
|
||||
public groupId?: string;
|
||||
/**
|
||||
* 도각 편집에서만 쓰는 **보여 주기용 값** (2026-09-06 사용자 지시). 자리표
|
||||
* `{{공사명}}` 대신 실제 공사명을 그려 사용자가 어디에 무엇이 들어가는지 알게 한다.
|
||||
* 저장값(`label`)은 토큰 그대로다 — 값이 아니라 연결을 저장한다.
|
||||
*/
|
||||
public previewLabel: string | null = null;
|
||||
private readonly options: TextOptions;
|
||||
|
||||
constructor(
|
||||
@@ -55,7 +68,7 @@ export class TextEntity implements Entity {
|
||||
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
|
||||
const selected = parentSelected ?? isEntitySelected(this);
|
||||
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
|
||||
drawController.drawText(this.label, this.basePoint, this.options);
|
||||
drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options);
|
||||
// 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이
|
||||
// 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가
|
||||
// 없어 이 선이 실리지 않는다.
|
||||
@@ -114,6 +127,16 @@ export class TextEntity implements Entity {
|
||||
}
|
||||
|
||||
public getBoundingBox(): Box {
|
||||
const { boxWidth, boxHeight } = this.options;
|
||||
if (boxWidth && boxHeight) {
|
||||
// 자리표는 칸이 곧 경계다 — basePoint 가 칸 한가운데다.
|
||||
return new Box(
|
||||
this.basePoint.x - boxWidth / 2,
|
||||
this.basePoint.y - boxHeight / 2,
|
||||
this.basePoint.x + boxWidth / 2,
|
||||
this.basePoint.y + boxHeight / 2
|
||||
);
|
||||
}
|
||||
// TODO find better way of determining the text bounding box
|
||||
return new Box(
|
||||
this.basePoint.x,
|
||||
@@ -123,6 +146,12 @@ export class TextEntity implements Entity {
|
||||
);
|
||||
}
|
||||
|
||||
/** 자리표 칸 크기(mm)를 바꾼다. 글자 크기는 그대로 둔다. */
|
||||
public setBoxSize(width: number, height: number): void {
|
||||
this.options.boxWidth = Math.max(width, 1);
|
||||
this.options.boxHeight = Math.max(height, 1);
|
||||
}
|
||||
|
||||
public getTextOptions(): TextOptions {
|
||||
return this.options;
|
||||
}
|
||||
@@ -196,6 +225,8 @@ export class TextEntity implements Entity {
|
||||
fontFamily: this.options.fontFamily,
|
||||
bold: this.options.bold,
|
||||
italic: this.options.italic,
|
||||
boxWidth: this.options.boxWidth,
|
||||
boxHeight: this.options.boxHeight,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -220,6 +251,8 @@ export class TextEntity implements Entity {
|
||||
fontFamily: jsonEntity.shapeData.options.fontFamily,
|
||||
bold: jsonEntity.shapeData.options.bold,
|
||||
italic: jsonEntity.shapeData.options.italic,
|
||||
boxWidth: jsonEntity.shapeData.options.boxWidth,
|
||||
boxHeight: jsonEntity.shapeData.options.boxHeight,
|
||||
}
|
||||
);
|
||||
textEntity.id = jsonEntity.id;
|
||||
@@ -241,5 +274,8 @@ export interface TextJsonData {
|
||||
fontFamily: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
/** 도각 자리표 칸 크기(mm) — basePoint 가 칸 한가운데다. */
|
||||
boxWidth?: number;
|
||||
boxHeight?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { type DesignMeta, HtmlEvent } from '../App.types.ts';
|
||||
import { ImageEntity } from '../entities/ImageEntity.ts';
|
||||
import { TextEntity } from '../entities/TextEntity.ts';
|
||||
import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
||||
import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
getCanvas,
|
||||
getDesignMeta,
|
||||
getEntities,
|
||||
getFrameFields,
|
||||
getLayers,
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingDirty,
|
||||
@@ -40,6 +42,8 @@ interface DrawingLoadMessage {
|
||||
meta?: DesignMeta | null;
|
||||
/** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */
|
||||
frameEdit?: boolean;
|
||||
/** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */
|
||||
frameFields?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DrawingSaveRequestMessage {
|
||||
@@ -125,6 +129,27 @@ function registerTextDoubleClickEdit() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 자리표에 실제 값을 입힌다 — 저장값은 그대로 두고 **보여 주기만** 바꾼다
|
||||
* (2026-09-06 사용자 지시). 편집 화면에서 어디에 무엇이 들어가는지 보이게 하는 것이 목적.
|
||||
*/
|
||||
function applyFramePreview(): void {
|
||||
const fields = getFrameFields();
|
||||
if (Object.keys(fields).length === 0) return;
|
||||
const token = /^\{\{(.+)\}\}$/;
|
||||
for (const entity of getEntities()) {
|
||||
if (entity instanceof TextEntity) {
|
||||
const match = token.exec(entity.getLabel().trim());
|
||||
const value = match ? fields[match[1]] : undefined;
|
||||
entity.previewLabel = value ?? null;
|
||||
} else if (entity instanceof ImageEntity && entity.isPlaceholder()) {
|
||||
const match = token.exec((entity.getSourceData() ?? '').trim());
|
||||
const value = match ? fields[match[1]] : undefined;
|
||||
if (value) entity.setPreviewImage(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B08 parent page와 CAD 앱 사이의 same-origin JSON 경계다.
|
||||
* DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다.
|
||||
@@ -160,7 +185,8 @@ export function registerAisloDrawingBridge() {
|
||||
resetUndoBaseline();
|
||||
// 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영
|
||||
setDesignMeta(event.data.meta ?? null);
|
||||
setFrameEditMode(event.data.frameEdit === true);
|
||||
setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {});
|
||||
if (event.data.frameEdit) applyFramePreview();
|
||||
// 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도**
|
||||
// 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측:
|
||||
// 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중
|
||||
|
||||
@@ -188,6 +188,8 @@ let snapTrackingEnabled = true;
|
||||
let designMeta: DesignMeta | null = null;
|
||||
/** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */
|
||||
let frameEditMode = false;
|
||||
/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */
|
||||
let frameFields: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 —
|
||||
@@ -259,6 +261,7 @@ export const getGridEnabled = () => gridEnabled;
|
||||
export const getSnapTrackingEnabled = () => snapTrackingEnabled;
|
||||
export const getDesignMeta = (): DesignMeta | null => designMeta;
|
||||
export const isFrameEditMode = (): boolean => frameEditMode;
|
||||
export const getFrameFields = (): Record<string, string> => frameFields;
|
||||
export const isDrawingDirty = () => drawingDirty;
|
||||
/**
|
||||
* 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자
|
||||
@@ -490,8 +493,9 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
triggerReactUpdate(StateVariable.designMeta);
|
||||
};
|
||||
/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */
|
||||
export const setFrameEditMode = (enabled: boolean) => {
|
||||
export const setFrameEditMode = (enabled: boolean, fields: Record<string, string> = {}) => {
|
||||
frameEditMode = enabled;
|
||||
frameFields = enabled ? fields : {};
|
||||
notifyWindow(HtmlEvent.UPDATE_STATE);
|
||||
};
|
||||
// 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정).
|
||||
|
||||
Reference in New Issue
Block a user