Files
Aislo/B07_DesignDetail/openwebcad/src/entities/HatchEntity.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

268 lines
8.5 KiB
TypeScript

/**
* 채움 객체 — 해치(HATCH)·그라데이션(GRADIENT)·와이프아웃(WIPEOUT)이 공유한다.
* 경계는 닫힌 점렬 하나로 갖는다 (섬 경계는 아직 다루지 않는다).
*/
import { Box, Point, Polygon, Segment } from '@flatten-js/core';
import type { Shape, SnapPoint } from '../App.types';
import { SnapPointType } from '../App.types';
import type { DrawController } from '../drawControllers/DrawController';
import { hatchSpans } from '../helpers/geometry/hatch-lines';
import { getExportColor } from '../helpers/get-export-color';
import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis';
import { scalePoint } from '../helpers/scale-point';
import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../state';
import { type Entity, EntityName, type JsonEntity } from './Entity';
import type { LineEntity } from './LineEntity';
export type HatchStyle = 'solid' | 'pattern' | 'cross' | 'gradient';
export interface HatchOptions {
style: HatchStyle;
/** 채움 색 (solid·gradient 시작색) */
color: string;
/** gradient 끝색 */
color2?: string;
/** 패턴 선 간격 (도면 단위) */
spacing: number;
/** 패턴 선 각도 (라디안) */
angle: number;
}
const DEFAULT_OPTIONS: HatchOptions = {
style: 'pattern',
color: '#ffffff',
spacing: 1,
angle: Math.PI / 4,
};
const GRADIENT_STEPS = 48;
export class HatchEntity 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 points: Point[];
public options: HatchOptions;
constructor(layerId: string, points: Point[], options?: Partial<HatchOptions>) {
this.layerId = layerId;
this.points = points.map((point) => point.clone());
this.options = { ...DEFAULT_OPTIONS, ...options };
}
public getPoints(): Point[] {
return this.points;
}
public draw(
drawController: DrawController,
parentHighlighted?: boolean,
parentSelected?: boolean
): void {
if (this.points.length < 3) return;
const highlighted = parentHighlighted ?? isEntityHighlighted(this);
const selected = parentSelected ?? isEntitySelected(this);
if (this.options.style === 'solid') {
drawController.setFillStyles(this.options.color);
drawController.fillPolygon(...this.points);
} else if (this.options.style === 'gradient') {
this.drawGradient(drawController);
} else {
drawController.setLineStyles(highlighted, selected, this.options.color, this.lineWidth);
const angles =
this.options.style === 'cross'
? [this.options.angle, this.options.angle + Math.PI / 2]
: [this.options.angle];
for (const angle of angles) {
for (const [start, end] of hatchSpans(this.points, angle, this.options.spacing)) {
drawController.drawLine(start, end);
}
}
}
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
drawController.setLineStyles(
highlighted,
selected,
this.lineColor,
this.lineWidth,
this.lineDash
);
for (let index = 1; index < this.points.length; index++) {
drawController.drawLine(this.points[index - 1], this.points[index]);
}
}
/** 그라데이션 — 촘촘한 스캔선의 색을 조금씩 바꿔 표현한다 */
private drawGradient(drawController: DrawController): void {
const box = this.getBoundingBox();
const spacing = Math.max((box.ymax - box.ymin) / GRADIENT_STEPS, 1e-6);
const spans = hatchSpans(this.points, 0, spacing);
if (!spans.length) return;
const minY = Math.min(...spans.map(([start]) => start.y));
const maxY = Math.max(...spans.map(([start]) => start.y));
const range = maxY - minY || 1;
for (const [start, end] of spans) {
const ratio = (start.y - minY) / range;
drawController.setLineStyles(
false,
false,
mixColors(this.options.color, this.options.color2 ?? this.options.color, ratio),
2
);
drawController.drawLine(start, end);
}
}
public move(x: number, y: number) {
this.points = this.points.map((point) => point.translate(x, y));
}
public scale(scaleOrigin: Point, scaleFactor: number) {
this.points = this.points.map((point) => scalePoint(point, scaleOrigin, scaleFactor));
this.options.spacing *= scaleFactor;
}
public rotate(rotateOrigin: Point, angle: number) {
this.points = this.points.map((point) => point.rotate(angle, rotateOrigin));
}
public mirror(mirrorAxis: LineEntity) {
this.points = this.points.map((point) => mirrorPointOverAxis(point, mirrorAxis));
}
public clone(): HatchEntity {
return new HatchEntity(getActiveLayerId(), this.points, { ...this.options });
}
private toPolygon(): Polygon {
return new Polygon(this.points.map((point) => [point.x, point.y] as [number, number]));
}
public getBoundingBox(): Box {
const xs = this.points.map((point) => point.x);
const ys = this.points.map((point) => point.y);
return new Box(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys));
}
public intersectsWithBox(box: Box): boolean {
return this.getBoundingBox().intersect(box);
}
public isContainedInBox(box: Box): boolean {
const own = this.getBoundingBox();
return (
box.xmin <= own.xmin && box.ymin <= own.ymin && box.xmax >= own.xmax && box.ymax >= own.ymax
);
}
public getFirstPoint(): Point | null {
return this.points[0] ?? null;
}
public getShape(): Shape | null {
return this.points.length >= 3 ? this.toPolygon() : null;
}
public getSnapPoints(): SnapPoint[] {
return this.points.map((point) => ({ point, type: SnapPointType.LineEndPoint }));
}
public getIntersections(entity: Entity): Point[] {
const otherShape = entity.getShape();
if (!otherShape || this.points.length < 3) return [];
return this.toPolygon().intersect(otherShape);
}
public distanceTo(shape: Shape): [number, Segment] | null {
if (this.points.length < 3) return null;
const polygon = this.toPolygon();
// 채운 면 안쪽을 찍어도 잡히도록 내부는 거리 0으로 본다 (AutoCAD의 해치 선택)
if (shape instanceof Point && polygon.contains(shape)) {
return [0, new Segment(shape, shape)];
}
return polygon.distanceTo(shape) as [number, Segment];
}
public getSvgString(): string | null {
const path = this.points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
.join(' ');
const fill = this.options.style === 'solid' ? getExportColor(this.options.color) : 'none';
return `<path d="${path} Z" fill="${fill}" stroke="${getExportColor(this.lineColor)}" stroke-width="${this.lineWidth}" />`;
}
public getType(): EntityName {
return EntityName.Hatch;
}
public containsPointOnShape(point: Point): boolean {
if (this.points.length < 3) return false;
return this.toPolygon().contains(point);
}
public async toJson(): Promise<JsonEntity<HatchJsonData> | null> {
return {
id: this.id,
type: EntityName.Hatch,
lineColor: this.lineColor,
lineWidth: this.lineWidth,
lineDash: this.lineDash,
layerId: this.layerId,
shapeData: {
points: this.points.map((point) => ({ x: point.x, y: point.y })),
options: this.options,
},
};
}
public static async fromJson(jsonEntity: JsonEntity<HatchJsonData>): Promise<HatchEntity> {
if (!jsonEntity.shapeData) {
throw new Error('Invalid JSON entity of type Hatch: missing shapeData');
}
const hatch = new HatchEntity(
jsonEntity.layerId || getActiveLayerId(),
jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y)),
jsonEntity.shapeData.options
);
hatch.id = jsonEntity.id;
hatch.lineColor = jsonEntity.lineColor;
hatch.lineWidth = jsonEntity.lineWidth;
hatch.lineDash = jsonEntity.lineDash;
return hatch;
}
}
/** 두 hex 색을 ratio(0~1)로 섞는다 */
function mixColors(from: string, to: string, ratio: number): string {
const parse = (color: string) => {
const hex = color.replace('#', '');
const full = hex.length === 3 ? [...hex].map((char) => char + char).join('') : hex;
return [
Number.parseInt(full.slice(0, 2), 16),
Number.parseInt(full.slice(2, 4), 16),
Number.parseInt(full.slice(4, 6), 16),
];
};
const [r1, g1, b1] = parse(from);
const [r2, g2, b2] = parse(to);
const channel = (a: number, b: number) =>
Math.round(a + (b - a) * Math.min(1, Math.max(0, ratio)))
.toString(16)
.padStart(2, '0');
return `#${channel(r1, r2)}${channel(g1, g2)}${channel(b1, b2)}`;
}
export interface HatchJsonData {
points: { x: number; y: number }[];
options: HatchOptions;
}