파일마다 포맷 폭이 달라(≈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>
298 lines
9.3 KiB
TypeScript
298 lines
9.3 KiB
TypeScript
import { Point, Vector } from '@flatten-js/core';
|
|
import { toast } from 'react-toastify';
|
|
import { SVG_MARGIN, TO_DEGREES } from '../App.consts.ts';
|
|
import type { TextOptions } from '../entities/TextEntity.ts';
|
|
import { isLengthEqual } from '../helpers/is-length-equal.ts';
|
|
import { StateVariable } from '../helpers/undo-stack.ts';
|
|
import { triggerReactUpdate } from '../state.ts';
|
|
import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
|
|
|
|
export class SvgDrawController implements DrawController {
|
|
private lineColor = '#000';
|
|
private lineWidth = 1;
|
|
private lineDash: number[] = [];
|
|
private svgStrings: string[] = [];
|
|
private fillColor = '#000';
|
|
private screenScale = 1;
|
|
private screenOffset = new Point(0, 0);
|
|
|
|
constructor(
|
|
private boundingBoxMinX: number,
|
|
private boundingBoxMinY: number,
|
|
private boundingBoxMaxX: number,
|
|
private boundingBoxMaxY: number
|
|
) {
|
|
this.setScreenOffset(new Point(boundingBoxMinX - SVG_MARGIN, boundingBoxMinY + SVG_MARGIN));
|
|
}
|
|
|
|
getCanvasSize(): Point {
|
|
return new Point(
|
|
this.boundingBoxMaxX - this.boundingBoxMinX,
|
|
this.boundingBoxMaxY - this.boundingBoxMinY
|
|
);
|
|
}
|
|
|
|
public getScreenScale() {
|
|
return this.screenScale;
|
|
}
|
|
|
|
public setScreenScale(newScreenScale: number) {
|
|
this.screenScale = newScreenScale;
|
|
triggerReactUpdate(StateVariable.screenZoom);
|
|
}
|
|
|
|
public getScreenOffset() {
|
|
return this.screenOffset;
|
|
}
|
|
|
|
public setScreenOffset(newScreenOffset: Point) {
|
|
this.screenOffset = newScreenOffset;
|
|
triggerReactUpdate(StateVariable.screenOffset);
|
|
}
|
|
|
|
/**
|
|
* Convert coordinates from World Space --> Screen Space
|
|
*/
|
|
public worldToTarget(worldCoordinate: Point): Point {
|
|
return new Point(
|
|
(worldCoordinate.x - this.screenOffset.x) * this.screenScale,
|
|
-1 * ((worldCoordinate.y - this.screenOffset.y) * this.screenScale - this.getCanvasSize().y)
|
|
);
|
|
}
|
|
|
|
public worldsToTargets(worldCoordinates: Point[]): Point[] {
|
|
return worldCoordinates.map(this.worldToTarget.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Convert coordinates from Screen Space --> World Space
|
|
* (0, 0) (1920, 0)
|
|
*
|
|
* (0, 1080) (1920, 1080)
|
|
*
|
|
* convert to
|
|
*
|
|
* (0, 1080) (1920, 1080)
|
|
*
|
|
* (0, 0) (1920, 0)
|
|
*/
|
|
public targetToWorld(screenCoordinate: Point): Point {
|
|
return new Point(
|
|
screenCoordinate.x / this.screenScale + this.screenOffset.x,
|
|
this.getCanvasSize().y - screenCoordinate.y / this.screenScale + this.screenOffset.y
|
|
);
|
|
}
|
|
|
|
public targetsToWorlds(screenCoordinates: Point[]): Point[] {
|
|
return screenCoordinates.map(this.targetToWorld.bind(this));
|
|
}
|
|
|
|
public clear() {
|
|
this.svgStrings = [];
|
|
}
|
|
|
|
public setLineStyles(
|
|
_isHighlighted: boolean,
|
|
_isSelected: boolean,
|
|
lineColor: string,
|
|
lineWidth: number,
|
|
lineDash: number[] = []
|
|
) {
|
|
if (
|
|
lineColor.toLowerCase() === '#fff' ||
|
|
lineColor.toLowerCase() === '#ffffff' ||
|
|
lineColor === 'white'
|
|
) {
|
|
this.lineColor = '#000';
|
|
} else if (
|
|
lineColor.toLowerCase() === '#000' ||
|
|
lineColor.toLowerCase() === '#000000' ||
|
|
lineColor === 'black'
|
|
) {
|
|
this.lineColor = '#FFF';
|
|
} else {
|
|
this.lineColor = lineColor;
|
|
}
|
|
this.lineWidth = lineWidth;
|
|
this.lineDash = lineDash;
|
|
}
|
|
|
|
/** SVG 내보내기는 투명도를 쓰지 않는다 (출력은 항상 불투명) */
|
|
public setOpacity(_opacity: number) {}
|
|
|
|
public setFillStyles(fillColor: string) {
|
|
if (
|
|
fillColor.toLowerCase() === '#fff' ||
|
|
fillColor.toLowerCase() === '#ffffff' ||
|
|
fillColor === 'white'
|
|
) {
|
|
this.fillColor = '#000';
|
|
} else if (
|
|
fillColor.toLowerCase() === '#000' ||
|
|
fillColor.toLowerCase() === '#000000' ||
|
|
fillColor === 'black'
|
|
) {
|
|
this.fillColor = '#FFF';
|
|
} else {
|
|
this.fillColor = fillColor;
|
|
}
|
|
}
|
|
|
|
public export() {
|
|
const boundingBoxWidth = Math.ceil(
|
|
this.boundingBoxMaxX - this.boundingBoxMinX + SVG_MARGIN * 2
|
|
);
|
|
const boundingBoxHeight = Math.ceil(
|
|
this.boundingBoxMaxY - this.boundingBoxMinY + SVG_MARGIN * 2
|
|
);
|
|
|
|
const svgLines = [
|
|
`<svg width="${boundingBoxWidth}" height="${boundingBoxHeight}" viewBox="0 0 ${boundingBoxWidth} ${boundingBoxHeight}" xmlns="http://www.w3.org/2000/svg">\n`,
|
|
` <rect x="0" y="0" width="${boundingBoxWidth}" height="${boundingBoxHeight}" fill="#FFF" />\n`,
|
|
...this.svgStrings.map((svgString) => `\t${svgString}\n`),
|
|
'</svg>',
|
|
];
|
|
|
|
return {
|
|
svgLines,
|
|
width: boundingBoxWidth,
|
|
height: boundingBoxHeight,
|
|
};
|
|
}
|
|
|
|
public drawLine(startPoint: Point, endPoint: Point): void {
|
|
const [canvasStartPoint, canvasEndPoint] = this.worldsToTargets([startPoint, endPoint]);
|
|
this.svgStrings.push(
|
|
`<line x1="${canvasStartPoint.x}" y1="${canvasStartPoint.y}" x2="${canvasEndPoint.x}" y2="${canvasEndPoint.y}" stroke="${this.lineColor}" stroke-width="${this.lineWidth}" stroke-dasharray="${this.lineDash.join(',')}" stroke-linecap="round" />`
|
|
);
|
|
}
|
|
|
|
public drawArc(
|
|
centerPoint: Point,
|
|
radius: number,
|
|
startAngle: number,
|
|
endAngle: number,
|
|
counterClockwise: boolean
|
|
) {
|
|
const canvasCenterPoint = this.worldToTarget(centerPoint);
|
|
const canvasRadius = radius * this.screenScale;
|
|
|
|
// Calculate start and end points of the arc
|
|
let startPoint = new Point(canvasCenterPoint.x + canvasRadius, canvasCenterPoint.y);
|
|
startPoint = startPoint.rotate(startAngle, canvasCenterPoint);
|
|
let endPoint = new Point(canvasCenterPoint.x + canvasRadius, canvasCenterPoint.y);
|
|
endPoint = endPoint.rotate(endAngle, canvasCenterPoint);
|
|
|
|
// Normalize the sweep angle to be between 0 and 2π
|
|
let sweep = endAngle - startAngle;
|
|
if (counterClockwise && sweep > 0) {
|
|
sweep -= 2 * Math.PI;
|
|
} else if (!counterClockwise && sweep < 0) {
|
|
sweep += 2 * Math.PI;
|
|
}
|
|
|
|
const largeArcFlag = Math.abs(sweep) > Math.PI ? '1' : '0';
|
|
const sweepFlag = counterClockwise ? '0' : '1'; // SVG: 0 = CCW, 1 = CW
|
|
|
|
const attributes = `fill="none" stroke="${this.lineColor}" stroke-width="${this.lineWidth}" stroke-dasharray="${this.lineDash.join(',')}" stroke-linecap="round"`;
|
|
let svgPath: string;
|
|
if (isLengthEqual(sweep, 2 * Math.PI)) {
|
|
svgPath = `<circle cx="${canvasCenterPoint.x}" cy="${canvasCenterPoint.y}" r="${canvasRadius}" ${attributes} />`;
|
|
} else {
|
|
svgPath = `<path d="M${startPoint.x},${startPoint.y} A${canvasRadius},${canvasRadius} 0 ${largeArcFlag},${sweepFlag} ${endPoint.x},${endPoint.y}" ${attributes} />`;
|
|
}
|
|
|
|
// Push the SVG path data string to the svgStrings array
|
|
this.svgStrings.push(svgPath);
|
|
}
|
|
|
|
public drawText(label: string, basePoint: Point, options?: Partial<TextOptions>): void {
|
|
const canvasBasePoint = this.worldToTarget(basePoint);
|
|
|
|
const textOptions = {
|
|
...DEFAULT_TEXT_OPTIONS,
|
|
...options,
|
|
};
|
|
|
|
let finalTextColor = textOptions.textColor;
|
|
const lowerCaseTextColor = textOptions.textColor.toLowerCase();
|
|
if (
|
|
lowerCaseTextColor === '#fff' ||
|
|
lowerCaseTextColor === '#ffffff' ||
|
|
lowerCaseTextColor === 'white'
|
|
) {
|
|
finalTextColor = '#000'; // Change to black if current color is white
|
|
}
|
|
// No need to handle black to white, as SVG background is white.
|
|
// Other colors will remain as they are.
|
|
|
|
let transformAttribute = '';
|
|
if (textOptions.textDirection) {
|
|
const angle = textOptions.textDirection.angleTo(new Vector(1, 0)) * TO_DEGREES;
|
|
transformAttribute = `transform="rotate(${angle}, ${canvasBasePoint.x}, ${canvasBasePoint.y})"`;
|
|
}
|
|
|
|
let textAnchorAttribute = '';
|
|
if (textOptions.textAlign === 'center') {
|
|
textAnchorAttribute = 'text-anchor="middle"';
|
|
}
|
|
|
|
this.svgStrings.push(
|
|
// Use finalTextColor here
|
|
`<text x="${canvasBasePoint.x}" y="${canvasBasePoint.y}" fill="${finalTextColor}" font-size="${textOptions.fontSize}" font-family="${textOptions.fontFamily}"${textOptions.bold ? ' font-weight="bold"' : ''}${textOptions.italic ? ' font-style="italic"' : ''} ${transformAttribute} ${textAnchorAttribute}>${label}</text>`
|
|
);
|
|
}
|
|
|
|
public drawImage(
|
|
imageElement: HTMLImageElement,
|
|
xMin: number,
|
|
yMin: number,
|
|
width: number,
|
|
height: number,
|
|
angle: number
|
|
): void {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = imageElement.width;
|
|
canvas.height = imageElement.height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
toast.warn('Failed to create canvas context');
|
|
console.warn('Failed to create canvas context');
|
|
return;
|
|
}
|
|
|
|
ctx.drawImage(imageElement, 0, 0);
|
|
const dataUri = canvas.toDataURL(); // Convert the image to Base64
|
|
|
|
const svgWidth = width * this.getScreenScale();
|
|
const svgHeight = height * this.getScreenScale();
|
|
|
|
const worldCenterX = xMin + width / 2;
|
|
const worldCenterY = yMin + height / 2;
|
|
|
|
const targetCenter = this.worldToTarget(new Point(worldCenterX, worldCenterY));
|
|
|
|
const svgX = targetCenter.x - svgWidth / 2;
|
|
const svgY = targetCenter.y - svgHeight / 2;
|
|
|
|
let transformAttribute = '';
|
|
if (angle !== 0) {
|
|
const svgAngleDegrees = angle * (180 / Math.PI);
|
|
transformAttribute = `transform="rotate(${svgAngleDegrees}, ${targetCenter.x}, ${targetCenter.y})"`;
|
|
}
|
|
|
|
// noinspection HtmlUnknownAttribute
|
|
this.svgStrings.push(
|
|
`<image href="${dataUri}" x="${svgX}" y="${svgY}" width="${svgWidth}" height="${svgHeight}" ${transformAttribute} />`
|
|
);
|
|
}
|
|
|
|
public fillPolygon(...points: Point[]) {
|
|
if (points.length < 3) return; // Polygon needs at least 3 points
|
|
const canvasPoints = this.worldsToTargets(points);
|
|
|
|
const pointsString = canvasPoints.map((p) => `${p.x},${p.y}`).join(' ');
|
|
this.svgStrings.push(`<polygon points="${pointsString}" fill="${this.fillColor}" />`);
|
|
}
|
|
}
|