Files
Aislo/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts
T
eomsangdonandClaude Opus 5 d0d3f2a311 feat(B07): 자리표 마우스 크기조절·기본 도각 칸 정렬·도면명 미리보기
- 칸이 있는 글자와 그림에 모서리 그립 추가, 모서리를 끌어 칸 크기를 바꾸는 조작 신설 (다른 도면 요소에도 동일 적용)
- 기본 도각(00_template_A1)의 글자 24개에 표제란 칸을 계산해 부여 — 칸 기준 가로·세로 가운데 정렬
- 도각 편집 시 도면명·도면번호도 보던 도면 값으로 미리보기 제공
- 복제 시 미리보기 값 유지 (그립 편집 후 자리표가 토큰으로 되돌아가던 문제)
- CAD index.html 을 캐시하지 않도록 처리 — 빌드 후에도 옛 화면이 남던 문제 해소

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 15:52:36 +09:00

336 lines
11 KiB
TypeScript

import type * as Flatten from '@flatten-js/core';
import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core';
import { type Shape, type SnapPoint, SnapPointType } from '../App.types';
import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController.ts';
import { twoPointBoxToPolygon } from '../helpers/box-to-polygon';
import { getExportColor } from '../helpers/get-export-color';
import { mirrorAngleOverAxis } from '../helpers/mirror-angle-over-axis.ts';
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
import { polygonToSegments } from '../helpers/polygon-to-segments';
import { scalePoint } from '../helpers/scale-point';
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state.ts';
import { type Entity, EntityName, type JsonEntity } from './Entity';
import type { LineEntity } from './LineEntity.ts';
export class ImageEntity implements Entity {
public id: string = crypto.randomUUID();
public lineColor = '#fff';
public lineWidth = 1;
public lineDash: number[] | undefined = undefined;
public layerId: string;
/** 0~1 객체 투명도 (Entity 인터페이스의 선택 항목) */
public opacity?: number;
/** GROUP으로 묶인 객체가 공유하는 식별자 */
public groupId?: string;
private imageElement: HTMLImageElement;
private polygon: Polygon;
private angle: number;
/**
* JSON에서 받은 그림 주소 원본. `imageElement.currentSrc`는 브라우저가 절대 URL로
* 바꿔 놓아, 도각의 자리표시자(`{{회사로고}}`)가 저장 한 번에
* `http://…/b07-cad/%7B%7B회사로고%7D%7D`로 굳는다(2026-09-02 실측 — 회사 도각이
* 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다.
*/
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,
imgData: HTMLImageElement,
startPointOrPolygon?: Point | Polygon,
endPointOrAngle?: Point | number,
angle = 0
) {
this.layerId = layerId;
this.imageElement = imgData;
if (startPointOrPolygon instanceof Polygon) {
this.polygon = startPointOrPolygon as Polygon;
} else {
this.polygon = twoPointBoxToPolygon(startPointOrPolygon as Point, endPointOrAngle as Point);
}
if (endPointOrAngle instanceof Point) {
this.angle = angle;
} else {
this.angle = endPointOrAngle as number;
}
}
public draw(
drawController: DrawController,
parentHighlighted?: boolean,
parentSelected?: boolean
): void {
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
const selected = parentSelected ?? isEntitySelected(this);
drawController.setLineStyles(
highlighted,
selected,
this.lineColor,
this.lineWidth,
this.lineDash
);
// 자리표(`{{회사로고}}` 등)는 그림이 없어 화면에 아무것도 안 보였다 — 도각 편집에서
// 무엇을 어디에 놓았는지 알 수 없어, 자리표일 때는 테두리와 이름을 늘 그린다
// (2026-09-06). 출력 때는 서버가 값으로 바꾸거나 엔티티째 빼므로 산출물에 안 실린다.
const placeholder = this.isPlaceholder() && !this.imageElement.src;
// 그 밖의 그림은 **집었을 때만** 테두리를 그린다. 늘 그리면 도각의 로고 자리에 흰
// 사각형이 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02).
if (highlighted || selected || placeholder) {
for (const edge of polygonToSegments(this.polygon)) {
drawController.drawLine(edge.start, edge.end);
}
}
if (placeholder) {
// 아직 보여 줄 그림이 없으면 이름표만 남긴다.
drawController.drawText(this.sourceData ?? '', this.polygon.box.center, {
...DEFAULT_TEXT_OPTIONS,
textAlign: 'center',
fontSize: Math.max(this.polygon.box.height / 4, 2),
textColor: this.lineColor,
});
return; // 그림이 없으니 그릴 것도 없다
}
// 칸 안에 **비율을 지켜** 넣는다 (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 + (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 setBoxFromCorners(a: Point, b: Point): void {
this.polygon = twoPointBoxToPolygon(
new Point(Math.min(a.x, b.x), Math.min(a.y, b.y)),
new Point(Math.max(a.x, b.x), Math.max(a.y, b.y))
);
}
public move(x: number, y: number) {
this.polygon = this.polygon.translate(new Vector(x, y));
}
public scale(scaleOrigin: Point, scaleFactor: number) {
const center = this.polygon.box.center;
const newCenter = scalePoint(center, scaleOrigin, scaleFactor);
this.polygon = this.polygon.translate(
new Vector(newCenter.x - center.x, newCenter.y - center.y)
);
}
public rotate(rotateOrigin: Point, angle: number) {
this.polygon = this.polygon.rotate(angle, rotateOrigin);
this.angle += angle; // Need to keep track of the angle for drawing the image
}
public mirror(mirrorAxis: LineEntity) {
const mirroredVertices = this.polygon.vertices.map((p) => mirrorPointOverAxis(p, mirrorAxis));
const mirroredAngle = mirrorAngleOverAxis(this.angle, mirrorAxis);
// TODO mirror image pixels
// this.imageElement = new HTMLImageElement(
// this.imageElement.
// )
this.polygon = new Polygon(mirroredVertices);
this.angle = mirroredAngle;
}
public clone(): ImageEntity {
const clonedImage = document.createElement('img');
clonedImage.src = this.imageElement.src;
const cloned = new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone());
cloned.sourceData = this.sourceData;
return cloned;
}
// TODO add destroy method to cleanup this.imageElement.src
public intersectsWithBox(selectionBox: Box): boolean {
return Relations.relate(this.polygon, selectionBox).B2B.length > 0;
}
public isContainedInBox(selectionBox: Box): boolean {
return selectionBox.contains(this.polygon);
}
public distanceTo(shape: Shape): [number, Segment] | null {
const distanceInfos: [number, Segment][] = polygonToSegments(this.polygon).map((segment) =>
segment.distanceTo(shape)
);
let shortestDistanceInfo: [number, Segment | null] = [Number.MAX_SAFE_INTEGER, null];
for (const distanceInfo of distanceInfos) {
if (distanceInfo[0] < shortestDistanceInfo[0]) {
shortestDistanceInfo = distanceInfo;
}
}
return shortestDistanceInfo as [number, Segment];
}
public getBoundingBox(): Box {
return this.polygon.box;
}
public getShape(): Shape | null {
return this.polygon;
}
public getSnapPoints(): SnapPoint[] {
const corners = this.polygon.vertices;
const edges = polygonToSegments(this.polygon);
return [
{
point: corners[0],
type: SnapPointType.LineEndPoint,
},
{
point: corners[1],
type: SnapPointType.LineEndPoint,
},
{
point: corners[2],
type: SnapPointType.LineEndPoint,
},
{
point: corners[3],
type: SnapPointType.LineEndPoint,
},
{
point: edges[0].middle(),
type: SnapPointType.LineMidPoint,
},
{
point: edges[1].middle(),
type: SnapPointType.LineMidPoint,
},
{
point: edges[2].middle(),
type: SnapPointType.LineMidPoint,
},
{
point: edges[3].middle(),
type: SnapPointType.LineMidPoint,
},
];
}
public getIntersections(entity: Entity): Point[] {
const otherShape = entity.getShape();
if (!otherShape) {
return [];
}
return polygonToSegments(this.polygon).flatMap((segment) => {
return segment.intersect(otherShape);
});
}
public getFirstPoint(): Point | null {
return this.polygon?.vertices[0] || null;
}
public getSvgString(): string | null {
return this.polygon.svg({
strokeWidth: this.lineWidth,
stroke: getExportColor(this.lineColor),
});
}
public getType(): EntityName {
return EntityName.Image;
}
public containsPointOnShape(point: Flatten.Point): boolean {
return polygonToSegments(this.polygon).some((segment) => segment.contains(point));
}
public async toJson(): Promise<JsonEntity<ImageJsonData> | null> {
return {
id: this.id,
type: EntityName.Image,
lineColor: this.lineColor,
lineWidth: this.lineWidth,
lineDash: this.lineDash,
layerId: this.layerId,
shapeData: {
points: this.polygon.vertices.map((vertex) => ({
x: vertex.x,
y: vertex.y,
})),
imageData: this.sourceData ?? this.imageElement.currentSrc,
},
};
}
public static async fromJson(jsonEntity: JsonEntity<ImageJsonData>): Promise<ImageEntity> {
if (!jsonEntity.shapeData) {
throw new Error('Invalid JSON entity of type Image: missing shapeData');
}
const rectangle = new Polygon(
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y))
);
const image = new Image();
// 자리표시자는 주소가 아니다 — 그대로 넣으면 404 요청이 나가고 그림이 'broken'
// 상태가 된다. 그 상태의 그림을 그리려 하면 캔버스가 예외를 던져 렌더 루프가
// 끊기고 **이후 모든 도면이 백지**로 남았다(2026-09-02 실측). 자리만 남긴다.
if (!jsonEntity.shapeData.imageData.includes('{{')) {
image.src = jsonEntity.shapeData.imageData;
}
const rectangleEntity = new ImageEntity(
jsonEntity.layerId || getActiveLayerId(),
image,
rectangle
);
rectangleEntity.id = jsonEntity.id;
rectangleEntity.lineColor = jsonEntity.lineColor;
rectangleEntity.lineWidth = jsonEntity.lineWidth;
rectangleEntity.lineDash = jsonEntity.lineDash;
rectangleEntity.sourceData = jsonEntity.shapeData.imageData;
return rectangleEntity;
}
}
export interface ImageJsonData {
points: { x: number; y: number }[];
imageData: string;
}