Files
Aislo/B07_wf4_DesignDetail/openwebcad/src/entities/MeasurementEntity.ts
T
2026-07-19 17:01:48 +09:00

577 lines
18 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_LENGTH,
ARROW_HEAD_WIDTH,
EPSILON,
MEASUREMENT_DECIMAL_PLACES,
MEASUREMENT_EXTENSION_LENGTH,
MEASUREMENT_FONT_SIZE,
MEASUREMENT_LABEL_OFFSET,
MEASUREMENT_ORIGIN_MARGIN,
TO_RADIANS,
} from '../App.consts';
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, isEntityHighlighted, isEntitySelected} from '../state.ts';
import {type Entity, EntityName, type JsonEntity} from './Entity';
import type {LineEntity} from './LineEntity.ts';
export class MeasurementEntity implements Entity {
public id: string = crypto.randomUUID();
public lineColor = '#fff';
public lineWidth = 1;
public lineDash: number[] | undefined = undefined;
public layerId: 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 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);
// Start of the perpendicular lines
const offsetStartPointMargin = this.startPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_ORIGIN_MARGIN)
);
const offsetEndPointMargin = this.endPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_ORIGIN_MARGIN)
);
// End of the perpendicular lines
const offsetStartPointExtend = offsetStartPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_EXTENSION_LENGTH)
);
const offsetEndPointExtend = offsetEndPoint
.clone()
.translate(
vectorPerpendicularFromLineTowardsOffsetPointUnit.multiply(MEASUREMENT_EXTENSION_LENGTH)
);
// Location for label
const midpointMeasurementLine = new Point(
(offsetStartPoint.x + offsetEndPoint.x) / 2,
(offsetStartPoint.y + offsetEndPoint.y) / 2
);
const textHeight = MEASUREMENT_FONT_SIZE;
const totalOffset = MEASUREMENT_LABEL_OFFSET + 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 => {
const screenScale = drawController.getScreenScale();
const vectorFromEndToStart = new Vector(endPoint, startPoint);
const vectorFromEndToStartUnit = vectorFromEndToStart.normalize();
const baseOfArrow = endPoint
.clone()
.translate(vectorFromEndToStartUnit.multiply(ARROW_HEAD_LENGTH * screenScale));
const perpendicularVector1 = vectorFromEndToStartUnit.rotate(90 * TO_RADIANS);
const perpendicularVector2 = vectorFromEndToStartUnit.rotate(-90 * TO_RADIANS);
const leftCornerOfArrow = baseOfArrow
.clone()
.translate(perpendicularVector1.multiply(ARROW_HEAD_WIDTH * screenScale));
const rightCornerOfArrow = baseOfArrow
.clone()
.translate(perpendicularVector2.multiply(ARROW_HEAD_WIDTH * screenScale));
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), MEASUREMENT_DECIMAL_PLACES)
);
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: MEASUREMENT_FONT_SIZE,
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), MEASUREMENT_DECIMAL_PLACES)
);
const textHeight = MEASUREMENT_FONT_SIZE;
// Estimate width: textString.length * fontSize * aspectRatioFactor
const textWidth = distance.length * MEASUREMENT_FONT_SIZE * 0.6;
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,
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;
return measurementEntity;
}
}
export interface MeasurementJsonData {
startPoint: { x: number; y: number };
endPoint: { x: number; y: number };
offsetPoint: { x: number; y: number };
}