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 | 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): Promise { 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; }