/** * 수정 명령의 객체 조작 — 간격띄우기·연장·길이조정·분해·결합·중복정리. * 기하 계산은 helpers/geometry의 순수 함수를 쓰고, 여기서는 엔티티로 바꾼다. */ import { Circle, Point, Segment, Vector } from '@flatten-js/core'; import { ArcEntity } from '../../entities/ArcEntity'; import { CircleEntity } from '../../entities/CircleEntity'; import { type Entity, EntityName } from '../../entities/Entity'; import type { HatchEntity } from '../../entities/HatchEntity'; import { LineEntity } from '../../entities/LineEntity'; import { PolyLineEntity } from '../../entities/PolyLineEntity'; import type { RectangleEntity } from '../../entities/RectangleEntity'; import type { TableEntity } from '../../entities/TableEntity'; import { TextEntity } from '../../entities/TextEntity'; import { dedupeConsecutive, sampleEntityPoints } from '../../helpers/geometry/sample-entity'; import { intersectLines, offsetPolylinePoints } from '../../helpers/geometry/shape-points'; import { polygonToSegments } from '../../helpers/polygon-to-segments'; import { cellRect, cellTextAnchor, tableBorders } from '../../helpers/table-geometry'; import { getActiveLayerId } from '../../state'; /** 원본 객체의 표시 특성을 새 객체에 옮긴다 */ export function copyStyle(source: Entity, target: T): T { target.lineColor = source.lineColor; target.lineWidth = source.lineWidth; target.lineDash = source.lineDash; target.layerId = source.layerId; return target; } const makeLine = (source: Entity, start: Point, end: Point): LineEntity => copyStyle(source, new LineEntity(source.layerId || getActiveLayerId(), start, end)); const makePolyLine = (source: Entity, points: Point[]): PolyLineEntity | null => { if (points.length < 2) return null; const segments: Entity[] = []; for (let index = 1; index < points.length; index++) { segments.push(makeLine(source, points[index - 1], points[index])); } return copyStyle(source, new PolyLineEntity(source.layerId || getActiveLayerId(), segments)); }; /** 점이 선의 어느 쪽에 있는지 (+1 왼쪽, -1 오른쪽) */ function sideOfLine(start: Point, end: Point, point: Point): number { const cross = (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x); return cross >= 0 ? 1 : -1; } /** OFFSET — 객체를 distance만큼 sidePoint 쪽으로 민 새 객체 */ export function offsetEntity(entity: Entity, distance: number, sidePoint: Point): Entity | null { const shape = entity.getShape(); if (shape instanceof Segment) { const side = sideOfLine(shape.start, shape.end, sidePoint); const points = offsetPolylinePoints([shape.start, shape.end], distance * side); return makeLine(entity, points[0], points[points.length - 1]); } if (shape instanceof Circle) { const outward = sidePoint.distanceTo(shape.center)[0] > Number(shape.r); const radius = Number(shape.r) + (outward ? distance : -distance); if (radius <= 0) return null; return copyStyle(entity, new CircleEntity(entity.layerId, shape.center, radius)); } if (entity.getType() === EntityName.Arc) { const arcShape = entity.getShape() as unknown as { center: Point; r: number; startAngle: number; endAngle: number; counterClockwise: boolean; }; const outward = sidePoint.distanceTo(arcShape.center)[0] > Number(arcShape.r); const radius = Number(arcShape.r) + (outward ? distance : -distance); if (radius <= 0) return null; return copyStyle( entity, new ArcEntity( entity.layerId, arcShape.center, radius, arcShape.startAngle, arcShape.endAngle, arcShape.counterClockwise ) ); } // 폴리선·사각형·해치 등은 점렬을 밀어서 만든다 const points = sampleEntityPoints(entity); if (points.length < 2) return null; const side = sideOfLine(points[0], points[1], sidePoint); return makePolyLine(entity, offsetPolylinePoints(points, distance * side)); } /** EXTEND — 대상 선을 경계 객체와 만나는 곳까지 늘린다 */ export function extendLineToBoundary(target: Entity, boundary: Entity): Entity | null { const shape = target.getShape(); if (!(shape instanceof Segment)) return null; const boundaryPoints = sampleEntityPoints(boundary); if (boundaryPoints.length < 2) return null; let best: { point: Point; distance: number; fromStart: boolean } | null = null; for (let index = 1; index < boundaryPoints.length; index++) { const crossing = intersectLines( shape.start, shape.end, boundaryPoints[index - 1], boundaryPoints[index] ); if (!crossing) continue; // 교점이 경계 선분 안에 있어야 한다 if (!isBetween(crossing, boundaryPoints[index - 1], boundaryPoints[index])) continue; const fromEnd = shape.end.distanceTo(crossing)[0]; const fromStart = shape.start.distanceTo(crossing)[0]; const useStart = fromStart < fromEnd; const distance = Math.min(fromStart, fromEnd); if (!best || distance < best.distance) { best = { point: crossing, distance, fromStart: useStart }; } } if (!best) return null; return best.fromStart ? makeLine(target, best.point, shape.end) : makeLine(target, shape.start, best.point); } /** 점이 두 점 사이 선분 위(오차 허용)에 있는가 */ export function isBetween(point: Point, start: Point, end: Point, tolerance = 1e-6): boolean { const minX = Math.min(start.x, end.x) - tolerance; const maxX = Math.max(start.x, end.x) + tolerance; const minY = Math.min(start.y, end.y) - tolerance; const maxY = Math.max(start.y, end.y) + tolerance; return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY; } /** LENGTHEN — 선의 끝을 delta만큼 늘리거나(양수) 줄인다(음수) */ export function lengthenLine(entity: Entity, delta: number, nearPoint: Point): Entity | null { const shape = entity.getShape(); if (!(shape instanceof Segment)) return null; const atStart = shape.start.distanceTo(nearPoint)[0] < shape.end.distanceTo(nearPoint)[0]; const length = shape.start.distanceTo(shape.end)[0] || 1; const dx = (shape.end.x - shape.start.x) / length; const dy = (shape.end.y - shape.start.y) / length; if (atStart) { return makeLine( entity, new Point(shape.start.x - dx * delta, shape.start.y - dy * delta), shape.end ); } return makeLine( entity, shape.start, new Point(shape.end.x + dx * delta, shape.end.y + dy * delta) ); } /** EXPLODE — 복합 객체를 구성요소로 나눈다. 나눌 게 없으면 빈 배열 */ export function explodeEntity(entity: Entity): Entity[] { if (entity.getType() === EntityName.PolyLine) { return (entity as PolyLineEntity).getEntities().map((child) => copyStyle(entity, child)); } if (entity.getType() === EntityName.Rectangle) { const polygon = (entity as RectangleEntity).getShape(); if (!polygon) return []; return polygonToSegments(polygon as never).map((segment) => makeLine(entity, segment.start, segment.end) ); } if (entity.getType() === EntityName.Table) { return explodeTable(entity as TableEntity); } if (entity.getType() === EntityName.Hatch) { const boundary = makePolyLine(entity, (entity as HatchEntity).getPoints()); return boundary ? [boundary] : []; } return []; } /** 표를 경계선과 칸 문자로 흩는다 (EXPLODE) */ function explodeTable(table: TableEntity): Entity[] { const origin = table.getOrigin(); const columnWidths = table.getColumnWidths(); const rowHeights = table.getRowHeights(); const style = table.getStyle(); const parts: Entity[] = tableBorders(origin, columnWidths, rowHeights, table.getCells()).map( (border) => makeLine(table, new Point(border.x1, border.y1), new Point(border.x2, border.y2)) ); for (let row = 0; row < rowHeights.length; row += 1) { for (let column = 0; column < columnWidths.length; column += 1) { const cell = table.getCell(row, column); if (!cell?.text) continue; const rect = cellRect(origin, columnWidths, rowHeights, row, column, cell); const anchor = cellTextAnchor(rect, cell.align, style.padding); const text = new TextEntity(table.layerId, cell.text, new Point(anchor.x, anchor.y), { textDirection: new Vector(1, 0), textAlign: cell.align ?? 'center', textColor: cell.color ?? style.textColor, fontSize: cell.fontSize ?? style.fontSize, fontFamily: style.fontFamily, bold: cell.bold, italic: cell.italic, }); parts.push(copyStyle(table, text)); } } return parts; } /** JOIN — 끝점이 맞닿는 객체들을 하나의 폴리선으로 잇는다 */ export function joinEntities(entities: Entity[], tolerance = 1e-3): PolyLineEntity | null { const chains = entities .map((entity) => sampleEntityPoints(entity)) .filter((points) => points.length >= 2); if (chains.length < 2) return null; const near = (a: Point, b: Point) => Math.abs(a.x - b.x) <= tolerance && Math.abs(a.y - b.y) <= tolerance; const remaining = [...chains]; let joined = remaining.shift() as Point[]; let progress = true; while (remaining.length && progress) { progress = false; const head = joined[0]; const tail = joined[joined.length - 1]; for (let index = 0; index < remaining.length; index++) { const chain = remaining[index]; const start = chain[0]; const end = chain[chain.length - 1]; if (near(start, tail)) joined = [...joined, ...chain.slice(1)]; else if (near(end, tail)) joined = [...joined, ...[...chain].reverse().slice(1)]; else if (near(end, head)) joined = [...chain.slice(0, -1), ...joined]; else if (near(start, head)) joined = [...[...chain].reverse().slice(0, -1), ...joined]; else continue; remaining.splice(index, 1); progress = true; break; } } if (remaining.length === chains.length - 1) return null; // 하나도 못 이었다 return makePolyLine(entities[0], dedupeConsecutive(joined)); } /** REVERSE — 방향을 뒤집은 새 객체 */ export function reverseEntity(entity: Entity): Entity | null { const shape = entity.getShape(); if (shape instanceof Segment) return makeLine(entity, shape.end, shape.start); const points = sampleEntityPoints(entity); if (points.length < 2) return null; return makePolyLine(entity, [...points].reverse()); } /** OVERKILL — 형상이 같은 객체의 중복분을 골라낸다 */ export function findDuplicateEntities(entities: Entity[], precision = 4): Entity[] { const seen = new Set(); const duplicates: Entity[] = []; for (const entity of entities) { const signature = `${entity.getType()}|${sampleEntityPoints(entity) .map((point) => `${point.x.toFixed(precision)},${point.y.toFixed(precision)}`) .join(';')}`; if (seen.has(signature)) duplicates.push(entity); else seen.add(signature); } return duplicates; }