- 칸이 있는 글자와 그림에 모서리 그립 추가, 모서리를 끌어 칸 크기를 바꾸는 조작 신설 (다른 도면 요소에도 동일 적용) - 기본 도각(00_template_A1)의 글자 24개에 표제란 칸을 계산해 부여 — 칸 기준 가로·세로 가운데 정렬 - 도각 편집 시 도면명·도면번호도 보던 도면 값으로 미리보기 제공 - 복제 시 미리보기 값 유지 (그립 편집 후 자리표가 토큰으로 되돌아가던 문제) - CAD index.html 을 캐시하지 않도록 처리 — 빌드 후에도 옛 화면이 남던 문제 해소 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
336 lines
11 KiB
TypeScript
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;
|
|
}
|