feat(B07): CAD를 AutoCAD 명령 체계로 재구성한다 (조사표 1·2·3·5절)
저장소 사고로 잃은 4개 커밋(38eb7cd4·b8a11756·281528bf·f60b488d)의 작업물을 하나로 다시 담았다. 내용은 동일하다. ■ 구조 - commands/: 명령 정의(id·AutoCAD 별칭·글리프·도구/즉시실행)를 단일 소스로 두고 리본·명령행·단축키가 모두 이 레지스트리를 읽는다. tools/tool.consts.ts 폐지. - ribbon/: 탭→패널→명령 데이터(ribbon.config.ts)와 범용 렌더러 분리. AutoCAD 배치(홈·삽입·주석·뷰·출력)와 패널 확장(▾)을 따른다. - tools/factories/sequence-tool.ts: 점·숫자·문자·객체·선택 단계를 선언하면 xstate 머신을 만들어 주는 공장. 명령당 170줄 보일러플레이트 제거. - helpers/geometry/: 3점 호·정다각형·타원·스플라인·구름형·평행이동·해치 스캔선· 점렬 샘플링 등 상태 없는 순수 함수. - Toolbar 483줄을 QuickAccessBar/Ribbon/InspectorPanel/StatusBar/CommandLine/ ViewControls/PropertiesEditor/QuickProperties로 분해. ■ 명령 (조사표 기준) - 1절 그리기 21건, 2절 수정 27건, 3절 도면층·특성·그룹·유틸리티 39건 전부 반영. - 5절 주석 34건 중 26건 반영(문자·치수 16종·지시선·표·구름형·주석 축척). - HatchEntity 추가(solid·pattern·cross·gradient) + JSON 왕복, Layer에 색·선가중치· 선종류·동결·투명도 필드 추가, 그리기 루프가 동결·숨김·투명도를 반영. ■ 화면 실측에서 고친 결함 - 명령행 포커스 상태에서 ENTER가 도구로 가지 않던 문제 - 명령행 문자 입력이 접두사가 같은 명령으로 실행되던 문제 - 명령이 끝나도 입력을 계속 먹던 문제(점 입력 명령만 반복, 나머지는 선택 도구 복귀) - 시퀀스 단계 인덱스 오사용 9건 + 단계 값 종류 검사 추가 - 해치 내부를 클릭해도 선택되지 않던 문제 미반영 8건(맞춤법 검사·꺾기 치수·치수 끊기/재연관/검사 치수·기하공차·지시선 수집· 축척 리스트 편집)은 조사표 `반영` 열과 PLAN.md에 사유를 적었다.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { blendEntities, breakEntity, chamferLines, filletLines } from './corner.helpers';
|
||||
import type { CornerResult } from './corner.helpers';
|
||||
|
||||
function applyCorner(first: Entity, second: Entity, result: CornerResult | null): void {
|
||||
if (!result) {
|
||||
toast.warn('두 직선을 선택해야 합니다. 두 선이 평행하면 처리할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([first, second], false);
|
||||
addEntities([...result.trimmed, ...(result.corner ? [result.corner] : [])], true);
|
||||
}
|
||||
|
||||
export const filletToolStateMachine = createSequenceTool({
|
||||
tool: Tool.FILLET,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '모깎기 반지름을 입력하십시오 <0>.', defaultValue: 0 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(1);
|
||||
const second = input.entity(2);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
filletLines(first, input.pick(1), second, input.pick(2), input.number(0))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const chamferToolStateMachine = createSequenceTool({
|
||||
tool: Tool.CHAMFER,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '첫 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'number', instructions: '두 번째 모따기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '첫 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '두 번째 객체를 남길 쪽에서 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const first = input.entity(2);
|
||||
const second = input.entity(3);
|
||||
applyCorner(
|
||||
first,
|
||||
second,
|
||||
chamferLines(
|
||||
first,
|
||||
input.pick(2),
|
||||
second,
|
||||
input.pick(3),
|
||||
input.number(0),
|
||||
input.number(1)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const blendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BLEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '혼합할 첫 번째 곡선을 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '혼합할 두 번째 곡선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const blend = blendEntities(input.entity(0), input.entity(1));
|
||||
if (!blend) {
|
||||
toast.warn('두 객체를 이을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([blend], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const breakToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '끊을 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 끊기 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 끊기 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1), input.point(2));
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const breakAtPointToolStateMachine = createSequenceTool({
|
||||
tool: Tool.BREAK_AT_POINT,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '나눌 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '나눌 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const pieces = breakEntity(entity, input.point(1));
|
||||
if (pieces.length < 2) {
|
||||
toast.warn('이 위치에서는 나눌 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities(pieces, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
/** 모깎기·모따기·곡선 혼합·끊기의 기하 계산과 객체 생성 */
|
||||
import { Point, Segment } from '@flatten-js/core';
|
||||
import { ArcEntity } from '../../entities/ArcEntity';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
||||
import {
|
||||
pointAtDistance,
|
||||
polylineLength,
|
||||
sampleEntityPoints,
|
||||
} from '../../helpers/geometry/sample-entity';
|
||||
import { intersectLines, splinePoints } from '../../helpers/geometry/shape-points';
|
||||
import { copyStyle } from './modify.helpers';
|
||||
|
||||
interface LinePick {
|
||||
entity: Entity;
|
||||
segment: Segment;
|
||||
pick: Point;
|
||||
}
|
||||
|
||||
const unit = (from: Point, to: Point): { x: number; y: number } => {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
return { x: dx / length, y: dy / length };
|
||||
};
|
||||
|
||||
const makeLine = (source: Entity, start: Point, end: Point): LineEntity =>
|
||||
copyStyle(source, new LineEntity(source.layerId, start, end));
|
||||
|
||||
/** 선을 집은 쪽에서 살아남는 끝점 (교차점 반대편 끝) */
|
||||
function keepEndpoint(segment: Segment, corner: Point, pick: Point): Point {
|
||||
const towardPick = unit(corner, pick);
|
||||
const towardStart = unit(corner, segment.start);
|
||||
const startAligned = towardPick.x * towardStart.x + towardPick.y * towardStart.y;
|
||||
return startAligned > 0 ? segment.start : segment.end;
|
||||
}
|
||||
|
||||
function asLinePick(entity: Entity, pick: Point): LinePick | null {
|
||||
const shape = entity.getShape();
|
||||
return shape instanceof Segment ? { entity, segment: shape, pick } : null;
|
||||
}
|
||||
|
||||
export interface CornerResult {
|
||||
/** 잘려서 새로 만들어진 두 선 */
|
||||
trimmed: Entity[];
|
||||
/** 모서리에 새로 놓이는 객체 (호 또는 선) */
|
||||
corner: Entity | null;
|
||||
}
|
||||
|
||||
/** FILLET — 두 선을 반지름 radius의 호로 잇는다 */
|
||||
export function filletLines(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
radius: number
|
||||
): CornerResult | null {
|
||||
const a = asLinePick(first, firstPick);
|
||||
const b = asLinePick(second, secondPick);
|
||||
if (!a || !b) return null;
|
||||
|
||||
const corner = intersectLines(a.segment.start, a.segment.end, b.segment.start, b.segment.end);
|
||||
if (!corner) return null;
|
||||
|
||||
const keepA = keepEndpoint(a.segment, corner, a.pick);
|
||||
const keepB = keepEndpoint(b.segment, corner, b.pick);
|
||||
const ua = unit(corner, keepA);
|
||||
const ub = unit(corner, keepB);
|
||||
|
||||
const angle = Math.acos(Math.min(1, Math.max(-1, ua.x * ub.x + ua.y * ub.y)));
|
||||
if (!Number.isFinite(angle) || angle < 1e-6 || Math.abs(angle - Math.PI) < 1e-6) return null;
|
||||
|
||||
if (radius <= 0) {
|
||||
// 반지름 0 = 두 선을 모서리에서 딱 맞춘다
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, corner), makeLine(b.entity, keepB, corner)],
|
||||
corner: null,
|
||||
};
|
||||
}
|
||||
|
||||
const tangentDistance = radius / Math.tan(angle / 2);
|
||||
const tangentA = new Point(
|
||||
corner.x + ua.x * tangentDistance,
|
||||
corner.y + ua.y * tangentDistance
|
||||
);
|
||||
const tangentB = new Point(
|
||||
corner.x + ub.x * tangentDistance,
|
||||
corner.y + ub.y * tangentDistance
|
||||
);
|
||||
|
||||
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
|
||||
const centerDistance = radius / Math.sin(angle / 2);
|
||||
const center = new Point(
|
||||
corner.x + bisector.x * centerDistance,
|
||||
corner.y + bisector.y * centerDistance
|
||||
);
|
||||
|
||||
const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x);
|
||||
const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x);
|
||||
const cross =
|
||||
(tangentA.x - center.x) * (tangentB.y - center.y) -
|
||||
(tangentA.y - center.y) * (tangentB.x - center.x);
|
||||
|
||||
const arc = copyStyle(
|
||||
a.entity,
|
||||
new ArcEntity(a.entity.layerId, center, radius, startAngle, endAngle, cross > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, tangentA), makeLine(b.entity, keepB, tangentB)],
|
||||
corner: arc,
|
||||
};
|
||||
}
|
||||
|
||||
/** CHAMFER — 두 선을 직선 모따기로 잇는다 */
|
||||
export function chamferLines(
|
||||
first: Entity,
|
||||
firstPick: Point,
|
||||
second: Entity,
|
||||
secondPick: Point,
|
||||
firstDistance: number,
|
||||
secondDistance: number
|
||||
): CornerResult | null {
|
||||
const a = asLinePick(first, firstPick);
|
||||
const b = asLinePick(second, secondPick);
|
||||
if (!a || !b) return null;
|
||||
|
||||
const corner = intersectLines(a.segment.start, a.segment.end, b.segment.start, b.segment.end);
|
||||
if (!corner) return null;
|
||||
|
||||
const keepA = keepEndpoint(a.segment, corner, a.pick);
|
||||
const keepB = keepEndpoint(b.segment, corner, b.pick);
|
||||
const ua = unit(corner, keepA);
|
||||
const ub = unit(corner, keepB);
|
||||
|
||||
const cutA = new Point(corner.x + ua.x * firstDistance, corner.y + ua.y * firstDistance);
|
||||
const cutB = new Point(corner.x + ub.x * secondDistance, corner.y + ub.y * secondDistance);
|
||||
|
||||
return {
|
||||
trimmed: [makeLine(a.entity, keepA, cutA), makeLine(b.entity, keepB, cutB)],
|
||||
corner: makeLine(a.entity, cutA, cutB),
|
||||
};
|
||||
}
|
||||
|
||||
/** BLEND — 두 객체의 가까운 끝점을 부드러운 곡선으로 잇는다 */
|
||||
export function blendEntities(first: Entity, second: Entity): Entity | null {
|
||||
const firstPoints = sampleEntityPoints(first);
|
||||
const secondPoints = sampleEntityPoints(second);
|
||||
if (firstPoints.length < 2 || secondPoints.length < 2) return null;
|
||||
|
||||
// 서로 가장 가까운 끝점 쌍을 고른다
|
||||
const candidates: [Point, Point, Point, Point][] = [
|
||||
[firstPoints[1], firstPoints[0], secondPoints[0], secondPoints[1]],
|
||||
[
|
||||
firstPoints[1],
|
||||
firstPoints[0],
|
||||
secondPoints[secondPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 2],
|
||||
],
|
||||
[
|
||||
firstPoints[firstPoints.length - 2],
|
||||
firstPoints[firstPoints.length - 1],
|
||||
secondPoints[0],
|
||||
secondPoints[1],
|
||||
],
|
||||
[
|
||||
firstPoints[firstPoints.length - 2],
|
||||
firstPoints[firstPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 1],
|
||||
secondPoints[secondPoints.length - 2],
|
||||
],
|
||||
];
|
||||
const best = candidates.reduce((chosen, candidate) =>
|
||||
candidate[1].distanceTo(candidate[2])[0] < chosen[1].distanceTo(chosen[2])[0]
|
||||
? candidate
|
||||
: chosen
|
||||
);
|
||||
|
||||
const curve = splinePoints([best[0], best[1], best[2], best[3]], 16);
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < curve.length; index++) {
|
||||
segments.push(makeLine(first, curve[index - 1], curve[index]));
|
||||
}
|
||||
return copyStyle(first, new PolyLineEntity(first.layerId, segments));
|
||||
}
|
||||
|
||||
/**
|
||||
* BREAK — 두 점 사이를 지운다. 한 점만 주면 그 자리에서 둘로 나눈다.
|
||||
* 선·호는 원래 형상을 유지하고, 그 밖의 객체는 점렬로 나눈다.
|
||||
*/
|
||||
export function breakEntity(entity: Entity, first: Point, second?: Point): Entity[] {
|
||||
const cutPoints = second ? [first, second] : [first];
|
||||
|
||||
const cuttable = entity as Entity & { cutAtPoints?: (points: Point[]) => Entity[] };
|
||||
if (typeof cuttable.cutAtPoints === 'function') {
|
||||
const pieces = cuttable.cutAtPoints(cutPoints).map((piece) => copyStyle(entity, piece));
|
||||
if (!second) return pieces;
|
||||
const middle = new Point((first.x + second.x) / 2, (first.y + second.y) / 2);
|
||||
return pieces.filter((piece) => !piece.containsPointOnShape(middle));
|
||||
}
|
||||
|
||||
const points = sampleEntityPoints(entity);
|
||||
if (points.length < 2) return [entity];
|
||||
const total = polylineLength(points);
|
||||
const firstDistance = distanceAlong(points, first);
|
||||
const secondDistance = second === undefined ? firstDistance : distanceAlong(points, second);
|
||||
const [from, to] = [firstDistance, secondDistance].sort((a, b) => a - b);
|
||||
|
||||
const head = pointsUpTo(points, from);
|
||||
const tail = pointsFrom(points, to, total);
|
||||
const pieces: Entity[] = [];
|
||||
for (const piece of [head, tail]) {
|
||||
if (piece.length >= 2) {
|
||||
const segments: Entity[] = [];
|
||||
for (let index = 1; index < piece.length; index++) {
|
||||
segments.push(makeLine(entity, piece[index - 1], piece[index]));
|
||||
}
|
||||
pieces.push(copyStyle(entity, new PolyLineEntity(entity.layerId, segments)));
|
||||
}
|
||||
}
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/** 점렬 시작점에서 target까지의 진행 거리 (가장 가까운 위치 기준) */
|
||||
function distanceAlong(points: Point[], target: Point): number {
|
||||
let bestDistance = 0;
|
||||
let bestGap = Number.POSITIVE_INFINITY;
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const segment = new Segment(points[index - 1], points[index]);
|
||||
const [gap, connector] = target.distanceTo(segment);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
bestDistance = travelled + points[index - 1].distanceTo(connector.end)[0];
|
||||
}
|
||||
travelled += points[index - 1].distanceTo(points[index])[0];
|
||||
}
|
||||
return bestDistance;
|
||||
}
|
||||
|
||||
function pointsUpTo(points: Point[], distance: number): Point[] {
|
||||
const result: Point[] = [];
|
||||
let travelled = 0;
|
||||
result.push(points[0]);
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
const step = points[index - 1].distanceTo(points[index])[0];
|
||||
if (travelled + step >= distance) break;
|
||||
travelled += step;
|
||||
result.push(points[index]);
|
||||
}
|
||||
const cut = pointAtDistance(points, distance);
|
||||
if (cut) result.push(cut);
|
||||
return result;
|
||||
}
|
||||
|
||||
function pointsFrom(points: Point[], distance: number, total: number): Point[] {
|
||||
if (distance >= total) return [];
|
||||
const result: Point[] = [];
|
||||
const cut = pointAtDistance(points, distance);
|
||||
if (cut) result.push(cut);
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
travelled += points[index - 1].distanceTo(points[index])[0];
|
||||
if (travelled > distance) result.push(points[index]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 수정 명령의 객체 조작 — 간격띄우기·연장·길이조정·분해·결합·중복정리.
|
||||
* 기하 계산은 helpers/geometry의 순수 함수를 쓰고, 여기서는 엔티티로 바꾼다.
|
||||
*/
|
||||
import { Circle, Point, Segment } 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 { dedupeConsecutive, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
|
||||
import { intersectLines, offsetPolylinePoints } from '../../helpers/geometry/shape-points';
|
||||
import { polygonToSegments } from '../../helpers/polygon-to-segments';
|
||||
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.Hatch) {
|
||||
const boundary = makePolyLine(entity, (entity as HatchEntity).getPoints());
|
||||
return boundary ? [boundary] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/** 특성 일치·그리기 순서·ByLayer·방향 반전·중복 정리·해치 편집 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { EntityName } from '../../entities/Entity';
|
||||
import type { HatchEntity, HatchStyle } from '../../entities/HatchEntity';
|
||||
import {
|
||||
addEntities,
|
||||
deleteEntities,
|
||||
getEntities,
|
||||
getLayerById,
|
||||
setEntities,
|
||||
setSelectedEntityIds,
|
||||
} from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { findDuplicateEntities, reverseEntity } from './modify.helpers';
|
||||
|
||||
export const matchPropToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MATCHPROP,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '특성을 가져올 원본 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '특성을 적용할 대상 객체를 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const source = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
target.lineColor = source.lineColor;
|
||||
target.lineWidth = source.lineWidth;
|
||||
target.lineDash = source.lineDash;
|
||||
target.layerId = source.layerId;
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
|
||||
/** 선택 객체를 목록 맨 앞(뒤)으로 옮겨 그리기 순서를 바꾼다 */
|
||||
function reorder(selected: Entity[], toFront: boolean): void {
|
||||
if (!selected.length) return;
|
||||
const ids = new Set(selected.map((entity) => entity.id));
|
||||
const rest = getEntities().filter((entity) => !ids.has(entity.id));
|
||||
setEntities(toFront ? [...rest, ...selected] : [...selected, ...rest], true);
|
||||
}
|
||||
|
||||
export const drawOrderFrontToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DRAWORDER_FRONT,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '맨 앞으로 보낼 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
reorder(input.entities(0), true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const drawOrderBackToolStateMachine = createSequenceTool({
|
||||
tool: Tool.DRAWORDER_BACK,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '맨 뒤로 보낼 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
reorder(input.entities(0), false);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const setByLayerToolStateMachine = createSequenceTool({
|
||||
tool: Tool.SETBYLAYER,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: 'ByLayer로 되돌릴 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
let applied = 0;
|
||||
for (const entity of input.entities(0)) {
|
||||
const layer = getLayerById(entity.layerId);
|
||||
if (!layer) continue;
|
||||
if (layer.color) entity.lineColor = layer.color;
|
||||
if (layer.lineWidth) entity.lineWidth = layer.lineWidth;
|
||||
entity.lineDash = layer.lineDash ? [...layer.lineDash] : undefined;
|
||||
applied += 1;
|
||||
}
|
||||
setEntities([...getEntities()], true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`${applied}개 객체를 도면층 특성으로 되돌렸습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
export const reverseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.REVERSE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '방향을 뒤집을 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const originals = input.entities(0);
|
||||
const reversed: Entity[] = [];
|
||||
const consumed: Entity[] = [];
|
||||
for (const entity of originals) {
|
||||
const flipped = reverseEntity(entity);
|
||||
if (flipped) {
|
||||
reversed.push(flipped);
|
||||
consumed.push(entity);
|
||||
}
|
||||
}
|
||||
if (!consumed.length) {
|
||||
toast.info('방향을 뒤집을 수 있는 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(consumed, false);
|
||||
addEntities(reversed, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const overkillToolStateMachine = createSequenceTool({
|
||||
tool: Tool.OVERKILL,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '중복을 정리할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const selected = input.entities(0);
|
||||
const target = selected.length ? selected : getEntities();
|
||||
const duplicates = findDuplicateEntities(target);
|
||||
if (!duplicates.length) {
|
||||
toast.info('중복된 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(duplicates, true);
|
||||
setSelectedEntityIds([]);
|
||||
toast.success(`중복 객체 ${duplicates.length}개를 삭제했습니다.`);
|
||||
},
|
||||
});
|
||||
|
||||
const HATCH_STYLES: HatchStyle[] = ['solid', 'pattern', 'cross', 'gradient'];
|
||||
|
||||
export const hatchEditToolStateMachine = createSequenceTool({
|
||||
tool: Tool.HATCHEDIT,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '편집할 해치를 선택하십시오.' },
|
||||
{
|
||||
kind: 'text',
|
||||
instructions: '패턴을 입력하십시오 (SOLID · PATTERN · CROSS · GRADIENT) <PATTERN>.',
|
||||
defaultValue: 'PATTERN',
|
||||
},
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
if (entity.getType() !== EntityName.Hatch) {
|
||||
toast.warn('해치 객체를 선택하십시오.');
|
||||
return;
|
||||
}
|
||||
const requested = input.text(1).trim().toLowerCase() as HatchStyle;
|
||||
if (!HATCH_STYLES.includes(requested)) {
|
||||
toast.warn('SOLID · PATTERN · CROSS · GRADIENT 중 하나를 입력하십시오.');
|
||||
return;
|
||||
}
|
||||
(entity as HatchEntity).options.style = requested;
|
||||
setEntities([...getEntities()], true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 연장·결합·분해·지우기 (조사표 2절) */
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { addEntities, deleteEntities, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { explodeEntity, extendLineToBoundary, joinEntities } from './modify.helpers';
|
||||
|
||||
export const extendToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXTEND,
|
||||
helpers: false,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '경계로 쓸 객체를 선택하십시오.' },
|
||||
{ kind: 'entity', instructions: '연장할 선을 선택하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const boundary = input.entity(0);
|
||||
const target = input.entity(1);
|
||||
const extended = extendLineToBoundary(target, boundary);
|
||||
if (!extended) {
|
||||
toast.warn('경계와 만나도록 연장할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([target], false);
|
||||
addEntities([extended], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const joinToolStateMachine = createSequenceTool({
|
||||
tool: Tool.JOIN,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '결합할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const joined = joinEntities(entities);
|
||||
if (!joined) {
|
||||
toast.warn('끝점이 맞닿는 객체가 없어 결합하지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(entities, false);
|
||||
addEntities([joined], true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const explodeToolStateMachine = createSequenceTool({
|
||||
tool: Tool.EXPLODE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '분해할 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
const exploded: Entity[] = [];
|
||||
const consumed: Entity[] = [];
|
||||
for (const entity of entities) {
|
||||
const parts = explodeEntity(entity);
|
||||
if (parts.length) {
|
||||
exploded.push(...parts);
|
||||
consumed.push(entity);
|
||||
}
|
||||
}
|
||||
if (!consumed.length) {
|
||||
toast.info('분해할 수 있는 복합 객체가 없습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities(consumed, false);
|
||||
addEntities(exploded, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const eraseToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ERASE,
|
||||
helpers: false,
|
||||
steps: [{ kind: 'selection', instructions: '지울 객체를 선택한 뒤 ENTER.' }],
|
||||
commit: (input) => {
|
||||
const entities = input.entities(0);
|
||||
if (!entities.length) return;
|
||||
deleteEntities(entities, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Entity } from '../../entities/Entity';
|
||||
import { LineEntity } from '../../entities/LineEntity';
|
||||
import { addEntities, deleteEntities, getActiveLayerId, setSelectedEntityIds } from '../../state';
|
||||
import { Tool } from '../../tools';
|
||||
import { createSequenceTool } from '../factories/sequence-tool';
|
||||
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
|
||||
|
||||
/** 원본 특성을 유지한 복사본 */
|
||||
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
|
||||
|
||||
export const mirrorToolStateMachine = createSequenceTool({
|
||||
tool: Tool.MIRROR,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '대칭 복사할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '대칭축의 첫 점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '대칭축의 두 번째 점을 지정하십시오.' },
|
||||
],
|
||||
preview: (input) => {
|
||||
const points = input.points();
|
||||
if (points.length !== 1) return [];
|
||||
return [new LineEntity(getActiveLayerId(), points[0], input.cursor)];
|
||||
},
|
||||
commit: (input) => {
|
||||
const [first, second] = input.points();
|
||||
const axis = new LineEntity(getActiveLayerId(), first, second);
|
||||
const mirrored = input.entities(0).map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.mirror(axis);
|
||||
return copy;
|
||||
});
|
||||
if (mirrored.length) addEntities(mirrored, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const alignToolStateMachine = createSequenceTool({
|
||||
tool: Tool.ALIGN,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '정렬할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '첫 번째 원본점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '첫 번째 대상점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 원본점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '두 번째 대상점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [source1, target1, source2, target2] = input.points();
|
||||
const sourceAngle = Math.atan2(source2.y - source1.y, source2.x - source1.x);
|
||||
const targetAngle = Math.atan2(target2.y - target1.y, target2.x - target1.x);
|
||||
const rotation = targetAngle - sourceAngle;
|
||||
|
||||
// 원본을 그대로 두고 사본을 변환해 교체한다 (실행취소가 원본을 되살릴 수 있어야 한다)
|
||||
const originals = input.entities(0);
|
||||
const aligned = originals.map((entity) => {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(target1.x - source1.x, target1.y - source1.y);
|
||||
copy.rotate(target1, rotation);
|
||||
return copy;
|
||||
});
|
||||
if (originals.length) {
|
||||
deleteEntities(originals, false);
|
||||
addEntities(aligned, true);
|
||||
}
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const offsetToolStateMachine = createSequenceTool({
|
||||
tool: Tool.OFFSET,
|
||||
steps: [
|
||||
{ kind: 'number', instructions: '간격띄우기 거리를 입력하십시오 <1>.', defaultValue: 1 },
|
||||
{ kind: 'entity', instructions: '간격띄우기할 객체를 선택하십시오.' },
|
||||
{ kind: 'point', instructions: '간격을 띄울 방향의 점을 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const result = offsetEntity(input.entity(1), input.number(0), input.point(2));
|
||||
if (!result) {
|
||||
toast.warn('이 객체는 지정한 거리로 간격을 띄울 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
addEntities([result], true);
|
||||
},
|
||||
});
|
||||
|
||||
export const stretchToolStateMachine = createSequenceTool({
|
||||
tool: Tool.STRETCH,
|
||||
steps: [
|
||||
{ kind: 'selection', instructions: '신축할 객체를 선택한 뒤 ENTER.' },
|
||||
{ kind: 'point', instructions: '기준점을 지정하십시오.' },
|
||||
{ kind: 'point', instructions: '이동할 위치를 지정하십시오.' },
|
||||
],
|
||||
commit: (input) => {
|
||||
const [base, destination] = input.points();
|
||||
const dx = destination.x - base.x;
|
||||
const dy = destination.y - base.y;
|
||||
const replaced: Entity[] = [];
|
||||
const removed: Entity[] = [];
|
||||
|
||||
for (const entity of input.entities(0)) {
|
||||
const shape = entity.getShape();
|
||||
// 선은 기준점에 가까운 끝점만 끌어당긴다. 그 밖의 객체는 통째로 옮긴다.
|
||||
if (shape && 'start' in shape && 'end' in shape) {
|
||||
const start = (shape as { start: Point }).start;
|
||||
const end = (shape as { end: Point }).end;
|
||||
const moveStart = start.distanceTo(base)[0] < end.distanceTo(base)[0];
|
||||
const newLine = new LineEntity(
|
||||
entity.layerId,
|
||||
moveStart ? new Point(start.x + dx, start.y + dy) : start,
|
||||
moveStart ? end : new Point(end.x + dx, end.y + dy)
|
||||
);
|
||||
replaced.push(copyStyle(entity, newLine));
|
||||
removed.push(entity);
|
||||
} else {
|
||||
const copy = cloneStyled(entity);
|
||||
copy.move(dx, dy);
|
||||
replaced.push(copy);
|
||||
removed.push(entity);
|
||||
}
|
||||
}
|
||||
if (removed.length) deleteEntities(removed, false);
|
||||
addEntities(replaced, true);
|
||||
setSelectedEntityIds([]);
|
||||
},
|
||||
});
|
||||
|
||||
export const lengthenToolStateMachine = createSequenceTool({
|
||||
tool: Tool.LENGTHEN,
|
||||
steps: [
|
||||
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
|
||||
{ kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 },
|
||||
],
|
||||
commit: (input) => {
|
||||
const entity = input.entity(0);
|
||||
const changed = lengthenLine(entity, input.number(1), input.pick(0));
|
||||
if (!changed) {
|
||||
toast.warn('선만 길이를 조정할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
deleteEntities([entity], false);
|
||||
addEntities([changed], true);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user