Files
Aislo/B07_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts
T
eomsangdonandClaude Opus 5 4cb9b15939 style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)
파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량
재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만.

- 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100)
- `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100).
  `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외
- 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경)

두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물
폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가
`core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가
실제 포맷 차이를 가리고 있었음.

검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped /
0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음).
포맷터 재실행 시 prettier·biome 모두 변경 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:08:24 +09:00

623 lines
19 KiB
TypeScript

import { Box, Line, Point, Segment, Vector } from '@flatten-js/core';
import { minBy, round } from 'es-toolkit';
import { max, min } from 'es-toolkit/compat';
import {
ARROW_HEAD_WIDTH,
EPSILON,
MEASUREMENT_EXTENSION_LENGTH,
MEASUREMENT_LABEL_OFFSET,
MEASUREMENT_ORIGIN_MARGIN,
TO_RADIANS,
} from '../App.consts';
import { getDimArrowSize, getDimDecimals, getDimTextHeight } from '../commands/dim-settings';
import type { Shape, SnapPoint } from '../App.types';
import type { DrawController } from '../drawControllers/DrawController';
import { pointDistance } from '../helpers/distance-between-points';
import { isPointEqual } from '../helpers/is-point-equal';
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts';
import { scalePoint } from '../helpers/scale-point';
import {
getActiveLayerId,
getScreenCanvasDrawController,
isEntityHighlighted,
isEntitySelected,
} from '../state.ts';
import { type Entity, EntityName, type JsonEntity } from './Entity';
import type { LineEntity } from './LineEntity.ts';
/**
* 치수 상수는 화면 픽셀 기준이므로 현재 줌 배율(px/world)로 나눠 세계좌표 길이로 바꾼다.
* 컨트롤러가 아직 없는 환경(단위 테스트 등)에서는 1을 반환해 상수를 그대로 쓴다.
*/
function annotationWorldFactor(): number {
try {
return getScreenCanvasDrawController().getScreenScale() || 1;
} catch {
return 1;
}
}
export class MeasurementEntity 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 startPoint: Point;
private endPoint: Point;
private offsetPoint: Point;
constructor(layerId: string, startPoint: Point, endPoint: Point, offsetPoint: Point) {
this.layerId = layerId;
this.startPoint = startPoint;
this.endPoint = endPoint;
this.offsetPoint = offsetPoint;
}
public getStartPoint(): Point {
return this.startPoint;
}
public getEndPoint(): Point {
return this.endPoint;
}
public getOffsetPoint(): Point {
return this.offsetPoint;
}
public setOffsetPoint(point: Point): void {
this.offsetPoint = point;
}
public getDrawPoints() {
// Return if measurement is zero length
if (isPointEqual(this.startPoint, this.endPoint)) {
return null;
}
// Base line of measurement
const lineStartToEnd = new Line(this.startPoint, this.endPoint);
// Calculate distance to offset point
const [, segment] = this.offsetPoint.distanceTo(lineStartToEnd);
const closestPointToOffsetOnLine = segment.end;
// Calculate 2 extension lines
let vectorPerpendicularFromLineTowardsOffsetPoint: Vector;
if (isPointEqual(closestPointToOffsetOnLine, this.offsetPoint)) {
// Offset point lies on baseline
vectorPerpendicularFromLineTowardsOffsetPoint = lineStartToEnd.norm;
} else {
// Offset point doesn't lie on baseline
vectorPerpendicularFromLineTowardsOffsetPoint = new Vector(
closestPointToOffsetOnLine,
this.offsetPoint
);
}
// Unit vector for offset direction
const vectorPerpendicularFromLineTowardsOffsetPointUnit =
vectorPerpendicularFromLineTowardsOffsetPoint.normalize();
// Points for horizontal measurement line
const offsetStartPoint = this.startPoint
.clone()
.translate(vectorPerpendicularFromLineTowardsOffsetPoint);
const offsetEndPoint = this.endPoint
.clone()
.translate(vectorPerpendicularFromLineTowardsOffsetPoint);
// Screen-pixel constants are converted to world units so annotation size stays zoom-independent
const worldFactor = annotationWorldFactor();
// Start of the perpendicular lines
const offsetStartPointMargin = this.startPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
MEASUREMENT_ORIGIN_MARGIN / worldFactor
)
);
const offsetEndPointMargin = this.endPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
MEASUREMENT_ORIGIN_MARGIN / worldFactor
)
);
// End of the perpendicular lines
const offsetStartPointExtend = offsetStartPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
MEASUREMENT_EXTENSION_LENGTH / worldFactor
)
);
const offsetEndPointExtend = offsetEndPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(
MEASUREMENT_EXTENSION_LENGTH / worldFactor
)
);
// Location for label
const midpointMeasurementLine = new Point(
(offsetStartPoint.x + offsetEndPoint.x) / 2,
(offsetStartPoint.y + offsetEndPoint.y) / 2
);
const textHeight = getDimTextHeight() / worldFactor;
const totalOffset = MEASUREMENT_LABEL_OFFSET / worldFactor + textHeight / 2;
const midpointMeasurementLineOffset = midpointMeasurementLine
.clone()
.translate(vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(totalOffset));
// TEMPORARY LOGGING START
if (
this.startPoint.x === 0 &&
this.startPoint.y === 0 &&
this.endPoint.x === 100 &&
this.endPoint.y === 0 &&
this.offsetPoint.x === 0 &&
this.offsetPoint.y === 20
) {
// Condition to target the specific test
console.log('[DEBUG getDrawPoints] For test ((0,0)-(100,0), offset(0,20)):');
console.log('startPoint:', JSON.stringify(this.startPoint));
console.log('endPoint:', JSON.stringify(this.endPoint));
console.log('offsetPoint:', JSON.stringify(this.offsetPoint));
console.log('offsetStartPoint:', JSON.stringify(offsetStartPoint));
console.log('offsetEndPoint:', JSON.stringify(offsetEndPoint));
console.log('offsetStartPointMargin:', JSON.stringify(offsetStartPointMargin));
console.log('offsetStartPointExtend:', JSON.stringify(offsetStartPointExtend));
console.log('offsetEndPointMargin:', JSON.stringify(offsetEndPointMargin));
console.log('offsetEndPointExtend:', JSON.stringify(offsetEndPointExtend));
}
// TEMPORARY LOGGING END
return {
offsetStartPoint,
offsetEndPoint,
offsetStartPointExtend,
offsetEndPointExtend,
offsetStartPointMargin,
offsetEndPointMargin,
midpointMeasurementLineOffset,
normalUnit: vectorPerpendicularFromLineTowardsOffsetPointUnit,
};
}
/**
* Draws an arrow head which ends at the endPoint
* The start point doesn't really matter, only the direction
* the size of the arrow is determined by ARROW_HEAD_SIZE
* @param drawController
* @param startPoint
* @param endPoint
*/
private drawArrowHead = (
drawController: DrawController,
startPoint: Point,
endPoint: Point,
isHighlighted: boolean,
isSelected: boolean
): void => {
// Arrow heads keep a constant on-screen size: divide pixel constants by zoom (px/world)
const worldFactor = drawController.getScreenScale() || 1;
const vectorFromEndToStart = new Vector(endPoint, startPoint);
const vectorFromEndToStartUnit = vectorFromEndToStart.normalize();
const baseOfArrow = endPoint
.clone()
.translate(vectorFromEndToStartUnit.multiply(getDimArrowSize() / worldFactor));
const perpendicularVector1 = vectorFromEndToStartUnit.rotate(90 * TO_RADIANS);
const perpendicularVector2 = vectorFromEndToStartUnit.rotate(-90 * TO_RADIANS);
const leftCornerOfArrow = baseOfArrow
.clone()
.translate(perpendicularVector1.multiply(ARROW_HEAD_WIDTH / worldFactor));
const rightCornerOfArrow = baseOfArrow
.clone()
.translate(perpendicularVector2.multiply(ARROW_HEAD_WIDTH / worldFactor));
drawController.setLineStyles(
isHighlighted,
isSelected,
this.lineColor,
this.lineWidth,
this.lineDash
);
drawController.drawLine(endPoint, leftCornerOfArrow);
drawController.drawLine(endPoint, rightCornerOfArrow);
drawController.drawLine(leftCornerOfArrow, rightCornerOfArrow);
drawController.setFillStyles(this.lineColor);
drawController.fillPolygon(endPoint, leftCornerOfArrow, rightCornerOfArrow);
};
/**
* Drawing of measurement:
*
* offsetPoint offsetEndPoint
* __x___--->x
* offsetStartPoint ______----- \
* x<---- \
* \ x
* \ endPoint
* x
* startPoint
*
* @param drawController
* @param parentHighlighted
* @param parentSelected
*/
public draw(
drawController: DrawController,
parentHighlighted?: boolean,
parentSelected?: boolean
): void {
if (isPointEqual(this.startPoint, this.endPoint)) {
return; // We can't draw a measurement with 0 length
}
const isHighlighted = parentHighlighted ?? isEntityHighlighted(this);
const isSelected = parentSelected ?? isEntitySelected(this);
drawController.setLineStyles(
isHighlighted,
isSelected,
this.lineColor,
this.lineWidth,
this.lineDash
);
drawController.setFillStyles(this.lineColor);
const drawPoints = this.getDrawPoints();
if (!drawPoints) {
return;
}
const {
offsetStartPoint,
offsetEndPoint,
offsetStartPointExtend,
offsetEndPointExtend,
offsetStartPointMargin,
offsetEndPointMargin,
midpointMeasurementLineOffset,
normalUnit,
} = drawPoints;
this.drawArrowHead(drawController, offsetStartPoint, offsetEndPoint, isHighlighted, isSelected);
this.drawArrowHead(drawController, offsetEndPoint, offsetStartPoint, isHighlighted, isSelected);
drawController.setLineStyles(
isHighlighted,
isSelected,
this.lineColor,
this.lineWidth,
this.lineDash
);
drawController.drawLine(offsetStartPoint, offsetEndPoint);
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const originalTextDirection = normalUnit.rotate90CW();
let finalTextDirection = originalTextDirection;
if (
originalTextDirection.x < -EPSILON ||
(Math.abs(originalTextDirection.x) < EPSILON && originalTextDirection.y > EPSILON)
) {
finalTextDirection = new Vector(-originalTextDirection.x, -originalTextDirection.y);
}
drawController.drawText(distance, midpointMeasurementLineOffset, {
textAlign: 'center',
textDirection: finalTextDirection,
fontSize: getDimTextHeight() / (drawController.getScreenScale() || 1),
textColor: this.lineColor,
});
}
public move(x: number, y: number) {
this.startPoint = this.startPoint.translate(x, y);
this.endPoint = this.endPoint.translate(x, y);
this.offsetPoint = this.offsetPoint.translate(x, y);
}
public scale(scaleOrigin: Point, scaleFactor: number) {
this.startPoint = scalePoint(this.startPoint, scaleOrigin, scaleFactor);
this.endPoint = scalePoint(this.endPoint, scaleOrigin, scaleFactor);
this.offsetPoint = scalePoint(this.offsetPoint, scaleOrigin, scaleFactor);
}
public rotate(rotateOrigin: Point, angle: number) {
this.startPoint = this.startPoint.rotate(angle, rotateOrigin);
this.endPoint = this.endPoint.rotate(angle, rotateOrigin);
this.offsetPoint = this.offsetPoint.rotate(angle, rotateOrigin);
}
public mirror(mirrorAxis: LineEntity) {
this.startPoint = mirrorPointOverAxis(this.startPoint, mirrorAxis);
this.endPoint = mirrorPointOverAxis(this.endPoint, mirrorAxis);
this.offsetPoint = mirrorPointOverAxis(this.offsetPoint, mirrorAxis);
}
public clone(): MeasurementEntity {
return new MeasurementEntity(
getActiveLayerId(),
this.startPoint.clone(),
this.endPoint.clone(),
this.offsetPoint.clone()
);
}
public intersectsWithBox(box: Box): boolean {
const drawPoints = this.getDrawPoints();
if (!drawPoints) {
return false;
}
const measurementLines = [
new Segment(drawPoints.offsetStartPoint, drawPoints.offsetEndPoint),
new Segment(drawPoints.offsetStartPointMargin, drawPoints.offsetStartPointExtend),
new Segment(drawPoints.offsetEndPointMargin, drawPoints.offsetEndPointExtend),
];
for (const line of measurementLines) {
if (line.intersect(box).length > 0) {
return true;
}
if (box.contains(line)) {
return true;
}
}
return false;
}
public isContainedInBox(box: Box): boolean {
const drawPoints = this.getDrawPoints();
if (!drawPoints) {
return false;
}
const measurementLines = [
new Segment(drawPoints.offsetStartPoint, drawPoints.offsetEndPoint),
new Segment(drawPoints.offsetStartPointMargin, drawPoints.offsetStartPointExtend),
new Segment(drawPoints.offsetEndPointMargin, drawPoints.offsetEndPointExtend),
];
for (const line of measurementLines) {
if (!box.contains(line)) {
return false;
}
}
return true;
}
public getBoundingBox(): Box {
const drawPoints = this.getDrawPoints();
if (!drawPoints) {
throw new Error('Failed to get draw points from measurement entity');
}
const lineExtremePoints = [
drawPoints.offsetStartPointMargin,
drawPoints.offsetStartPointExtend,
drawPoints.offsetEndPointMargin,
drawPoints.offsetEndPointExtend,
// Also include the main measurement line itself in the bounding box calculation for lines
drawPoints.offsetStartPoint,
drawPoints.offsetEndPoint,
];
// Calculate text properties
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const worldFactor = annotationWorldFactor();
const textHeight = getDimTextHeight() / worldFactor;
// Estimate width: textString.length * fontSize * aspectRatioFactor
const textWidth = (distance.length * getDimTextHeight() * 0.6) / worldFactor;
const { midpointMeasurementLineOffset, normalUnit } = drawPoints;
// Determine text direction (similar to draw method)
const originalTextDirection = normalUnit.rotate90CW();
let finalTextDirection = originalTextDirection;
if (
originalTextDirection.x < -EPSILON ||
(Math.abs(originalTextDirection.x) < EPSILON && originalTextDirection.y > EPSILON)
) {
finalTextDirection = new Vector(-originalTextDirection.x, -originalTextDirection.y);
}
// Text center
const textCenterX = midpointMeasurementLineOffset.x;
const textCenterY = midpointMeasurementLineOffset.y;
// Half dimensions
const halfTextWidth = textWidth / 2;
const halfTextHeight = textHeight / 2;
// Text corner calculations
// Vector along the text direction for width, and perpendicular for height
const dirVec = finalTextDirection.normalize(); // Vector along the text direction
const perpVec = dirVec.rotate90CW(); // Vector perpendicular to text direction (for height offset)
const textCorners = [
new Point(
textCenterX - dirVec.x * halfTextWidth - perpVec.x * halfTextHeight,
textCenterY - dirVec.y * halfTextWidth - perpVec.y * halfTextHeight
),
new Point(
textCenterX + dirVec.x * halfTextWidth - perpVec.x * halfTextHeight,
textCenterY + dirVec.y * halfTextWidth - perpVec.y * halfTextHeight
),
new Point(
textCenterX + dirVec.x * halfTextWidth + perpVec.x * halfTextHeight,
textCenterY + dirVec.y * halfTextWidth + perpVec.y * halfTextHeight
),
new Point(
textCenterX - dirVec.x * halfTextWidth + perpVec.x * halfTextHeight,
textCenterY - dirVec.y * halfTextWidth + perpVec.y * halfTextHeight
),
];
const allExtremePoints = [...lineExtremePoints, ...textCorners];
return new Box(
min(allExtremePoints.map((point) => point.x)),
min(allExtremePoints.map((point) => point.y)),
max(allExtremePoints.map((point) => point.x)),
max(allExtremePoints.map((point) => point.y))
);
}
public getShape(): Shape | null {
return null;
}
public getSnapPoints(): SnapPoint[] {
return [];
}
public getIntersections(): Point[] {
return [];
}
public getFirstPoint(): Point | null {
return this.startPoint;
}
public distanceTo(shape: Shape): [number, Segment] | null {
const drawPoints = this.getDrawPoints();
if (!drawPoints) {
return null;
}
const {
offsetStartPoint,
offsetEndPoint,
offsetStartPointExtend,
offsetEndPointExtend,
offsetStartPointMargin,
offsetEndPointMargin,
} = drawPoints;
const mainSegment = new Segment(offsetStartPoint, offsetEndPoint);
const horizontalLineDistanceInfo = mainSegment.distanceTo(shape);
const leftExtensionSegment = new Segment(offsetStartPointMargin, offsetStartPointExtend);
const leftVerticalLineDistanceInfo = leftExtensionSegment.distanceTo(shape);
const rightExtensionSegment = new Segment(offsetEndPointMargin, offsetEndPointExtend);
const rightVerticalLineDistanceInfo = rightExtensionSegment.distanceTo(shape);
return minBy(
[horizontalLineDistanceInfo, leftVerticalLineDistanceInfo, rightVerticalLineDistanceInfo],
(distanceInfo) => distanceInfo[0]
);
}
public getSvgString(): string | null {
throw new Error('getSvgString for MeasurementEntity not yet implemented');
// return (
// this.segment.svg({
// strokeWidth: this.lineWidth,
// stroke: getExportColor(this.lineColor),
// }) || null
// );
}
public getType(): EntityName {
return EntityName.Measurement;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public containsPointOnShape(point: Point): boolean {
const drawPoints = this.getDrawPoints();
if (!drawPoints) {
return false; // No visual representation, so no point can be on it.
}
const {
offsetStartPoint,
offsetEndPoint,
offsetStartPointMargin,
offsetStartPointExtend,
offsetEndPointMargin,
offsetEndPointExtend,
} = drawPoints;
const measurementLine = new Segment(offsetStartPoint, offsetEndPoint);
if (measurementLine.contains(point)) {
return true;
}
const extensionLine1 = new Segment(offsetStartPointMargin, offsetStartPointExtend);
if (extensionLine1.contains(point)) {
return true;
}
const extensionLine2 = new Segment(offsetEndPointMargin, offsetEndPointExtend);
if (extensionLine2.contains(point)) {
return true;
}
return false;
}
public async toJson(): Promise<JsonEntity<MeasurementJsonData> | null> {
return {
id: this.id,
type: EntityName.Measurement,
lineColor: this.lineColor,
lineWidth: this.lineWidth,
lineDash: this.lineDash,
layerId: this.layerId,
shapeData: {
startPoint: { x: this.startPoint.x, y: this.startPoint.y },
endPoint: { x: this.endPoint.x, y: this.endPoint.y },
offsetPoint: { x: this.offsetPoint.x, y: this.offsetPoint.y },
},
};
}
public static async fromJson(
jsonEntity: JsonEntity<MeasurementJsonData>
): Promise<MeasurementEntity> {
if (!jsonEntity.shapeData) {
throw new Error('Invalid JSON entity of type Measurement: missing shapeData');
}
const startPoint = new Point(
jsonEntity.shapeData.startPoint.x,
jsonEntity.shapeData.startPoint.y
);
const endPoint = new Point(jsonEntity.shapeData.endPoint.x, jsonEntity.shapeData.endPoint.y);
const offsetPoint = new Point(
jsonEntity.shapeData.offsetPoint.x,
jsonEntity.shapeData.offsetPoint.y
);
const measurementEntity = new MeasurementEntity(
jsonEntity.layerId || getActiveLayerId(),
startPoint,
endPoint,
offsetPoint
);
measurementEntity.id = jsonEntity.id;
measurementEntity.lineColor = jsonEntity.lineColor;
measurementEntity.lineWidth = jsonEntity.lineWidth;
measurementEntity.lineDash = jsonEntity.lineDash;
return measurementEntity;
}
}
export interface MeasurementJsonData {
startPoint: { x: number; y: number };
endPoint: { x: number; y: number };
offsetPoint: { x: number; y: number };
}