/** * 채움 객체 — 해치(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) { 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 ``; } 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 | 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): Promise { 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; }