- 등고선·세류선: 종이 0.1 mm(펜 굵기) 아래 굴곡 점 제거 — 294,339점 → 약 95,600점 - 배경선 캐시: 파이썬 튜플 → numpy 배열 + 선마다 범위 · 도곽 밖 선은 자르기 전에 거름 · 투영 한 줄씩 묶어 처리 - HatchEntity: 경계·무늬가 그대로면 줌·이동 때 패턴 선분 다시 계산 안 함 - 검증_sub4 유역도: 응답 75 MB → 24 MB · 서버 최고 1,304 MB → 595 MB · 끝 489 MB → 288 MB - 브라우저: 전 = 힙 289 MB 넘다 페이지 죽음 · 후 = 최고 203 MB 열림 · 줌 20번 275~281 MB 고정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
276 lines
9.0 KiB
TypeScript
276 lines
9.0 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 { type Span, 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;
|
|
/** 패턴 선분 — 줌·이동마다 다시 그리므로 경계·무늬가 그대로면 지난 계산을 쓴다. */
|
|
private spanCache?: { key: string; points: Point[]; spans: Span[] };
|
|
|
|
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);
|
|
for (const [start, end] of this.patternSpans()) {
|
|
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 patternSpans(): Span[] {
|
|
const { style, angle, spacing } = this.options;
|
|
const key = `${style}:${angle}:${spacing}`;
|
|
if (this.spanCache?.points === this.points && this.spanCache.key === key) {
|
|
return this.spanCache.spans;
|
|
}
|
|
const angles = style === 'cross' ? [angle, angle + Math.PI / 2] : [angle];
|
|
const spans = angles.flatMap((value) => hatchSpans(this.points, value, spacing));
|
|
this.spanCache = { key, points: this.points, spans };
|
|
return spans;
|
|
}
|
|
|
|
/** 그라데이션 — 촘촘한 스캔선의 색을 조금씩 바꿔 표현한다 */
|
|
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;
|
|
}
|