표가 DXF의 표로 나가야 한다(사용자 확정). 지금까지 도면의 표는 선과 문자 뭉치라 내보낼 때 고를 수 있는 길이 하나뿐이었다. - TableEntity: 열별 폭·행별 높이·칸 문자·병합을 한 객체가 들고 있다. 격자선은 담지 않고 병합 자리에서 선을 끊는 규칙을 표가 스스로 안다(helpers/table-geometry.ts). 회전·대칭은 지원하지 않는다 — 표는 축에 붙어 있다. - 명령: TABLE을 표 객체 생성으로 다시 쓰고 TABLEEDIT(칸 문자)·TABLEROW·TABLECOL· TABLEMERGE·TABLEUNMERGE를 더했다. EXPLODE는 표를 선과 문자로 흩는다. - 그립: 좌측 상단으로 표를 옮기고, 열·행 경계로 폭·높이를 바꾼다. - 백엔드: 유역 정보표와 횡단 수량 산출표를 표 객체로 낸다. 횡단표는 머리행이 폭 8등분, 본문이 11열 가중치로 격자가 서로 달라 두 경계를 합친 18열로 만들고 병합으로 원래 칸을 되살렸다 — 손으로 하던 가로선 끊기가 사라졌다. - 수량 역추출: 값 Text의 결정적 id로 읽던 것을 칸에 실은 key로 읽도록 옮겼다. 이미 저장된 도면을 위해 옛 방식을 폴백으로 남겼다. 토적도·종단표는 값이 칸이 아니라 측점 위치에 놓이는 성격이라 이관하지 않았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
/**
|
|
* 수정 명령의 객체 조작 — 간격띄우기·연장·길이조정·분해·결합·중복정리.
|
|
* 기하 계산은 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<T extends Entity>(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<string>();
|
|
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;
|
|
}
|