- COPY: 기준점 [변위(D)/모드(O)/다중(M)] · 두 번째 점 [배열(A)] → [배열(A)/종료(E)/명령 취소(U)] <종료> 되풀이 · 첫 빈 Enter = 변위 · COPYMODE 기억 · A 항목 수 · F 맞춤 - MIRROR: 대칭선 두 점 → 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오> · Esc = 안 만듦 - STRETCH: MOVE 틀 + 걸침 창 안 꼭짓점만(선 · 폴리선 토막 · 사각형) · 창 안 통째 · 하나씩 고른 것 = 이동 · STRETCH.DISP - ARRAY: 유형 [직사각형(R)/원형(PO)] <직사각형>(ARRAYTYPE) → 편집 물음 되풀이(COU · S · COL · R · X / I · A · F · ROT · X) · 미리보기 4 × 3 · 6 개 360° · 처음 간격 범위 1.5 배 · Esc = 안 만듦(N17 없어짐) - PEDIT: 공용 고르기 상태(객체 선택 · 빈 Enter · Esc = 끝 · 묶은 뒤 끝) - 시험 transform-acad 29 + ALL · L · P 1(선택 도구 처리 뒤 돎) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
233 lines
9.0 KiB
TypeScript
233 lines
9.0 KiB
TypeScript
/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 2절) */
|
|
import { Box, Point, Polygon } from '@flatten-js/core';
|
|
import { toast } from 'react-toastify';
|
|
import { type AnyActorRef, assign } from 'xstate';
|
|
import { OPT } from '../../commands/prompt';
|
|
import type { Entity } from '../../entities/Entity';
|
|
import { LineEntity } from '../../entities/LineEntity';
|
|
import { PolyLineEntity } from '../../entities/PolyLineEntity';
|
|
import { RectangleEntity } from '../../entities/RectangleEntity';
|
|
import {
|
|
addEntities,
|
|
deleteEntities,
|
|
getActiveLayerId,
|
|
getEntities,
|
|
setEntities,
|
|
setSelectedEntityIds,
|
|
} from '../../state';
|
|
import { Tool } from '../../tools';
|
|
import { createSequenceTool } from '../factories/sequence-tool';
|
|
import { createDisplaceTool } from '../move-tool';
|
|
import { SelectState } from '../select-tool';
|
|
import type { MouseClickEvent, StateEvent } from '../tool.types';
|
|
import { SELECT_CHILD } from '../transform.helpers';
|
|
import { copyStyle, lengthenLine, offsetEntity } from './modify.helpers';
|
|
|
|
/** 원본 특성을 유지한 복사본 */
|
|
const cloneStyled = (entity: Entity): Entity => copyStyle(entity, entity.clone());
|
|
|
|
/** 대칭선 — 두 점이 같으면 없음 */
|
|
const mirrorAxis = (first: Point, second: Point): LineEntity | null =>
|
|
first.distanceTo(second)[0] < 1e-9 ? null : new LineEntity(getActiveLayerId(), first, second);
|
|
|
|
const mirrored = (originals: Entity[], axis: LineEntity): Entity[] =>
|
|
originals.map((entity) => {
|
|
const copy = cloneStyled(entity);
|
|
copy.mirror(axis);
|
|
return copy;
|
|
});
|
|
|
|
/**
|
|
* MIRROR(337 라 · 설계 1-3) — 「객체 선택:」 → 「대칭선의 첫 번째 점 지정:」 → 「대칭선의 두 번째 점 지정:」 →
|
|
* 「원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오>:」 · Esc = 안 만듦 · 한 번 뒤 끝.
|
|
*/
|
|
export const mirrorToolStateMachine = createSequenceTool({
|
|
tool: Tool.MIRROR,
|
|
steps: [
|
|
{ kind: 'selection' },
|
|
{ kind: 'point', id: 'first', prompt: { text: '대칭선의 첫 번째 점 지정' } },
|
|
{ kind: 'point', id: 'second', prompt: { text: '대칭선의 두 번째 점 지정' } },
|
|
{
|
|
kind: 'keyword',
|
|
id: 'erase',
|
|
prompt: {
|
|
text: '원본 객체를 지우시겠습니까?',
|
|
options: [OPT.YES, OPT.NO],
|
|
default: { kind: 'option', key: OPT.NO.key },
|
|
},
|
|
},
|
|
],
|
|
preview: (input) => {
|
|
const [first, second] = input.points();
|
|
const axis = first ? mirrorAxis(first, second ?? input.cursor) : null;
|
|
if (!axis) return [];
|
|
return [...(second ? [] : [axis]), ...mirrored(input.entities(0), axis)];
|
|
},
|
|
commit: (input) => {
|
|
const [first, second] = input.points();
|
|
const axis = mirrorAxis(first, second);
|
|
const originals = input.entities(0);
|
|
if (!axis || !originals.length) return;
|
|
const copies = mirrored(originals, axis);
|
|
if (input.get('erase') === OPT.YES.key) {
|
|
const gone = new Set(originals.map((entity) => entity.id));
|
|
setEntities([...getEntities().filter((entity) => !gone.has(entity.id)), ...copies], true);
|
|
} else {
|
|
addEntities(copies, 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);
|
|
},
|
|
});
|
|
|
|
/** 바꾼 것을 원본 자리에 — id · 묶음 · 꼴 그대로 */
|
|
const inPlaceOf = <T extends Entity>(source: Entity, made: T): T => {
|
|
copyStyle(source, made);
|
|
made.id = source.id;
|
|
made.groupId = source.groupId;
|
|
made.opacity = source.opacity;
|
|
return made;
|
|
};
|
|
|
|
/**
|
|
* 걸침 창 신축 — 창 안 꼭짓점만 (dx, dy) · 창 안에 통째 든 것 · 하나씩 집은 것(창 밖) = 이동 · 그 밖 = 그대로(null).
|
|
* ponytail: 선 · 폴리선(선 토막) · 사각형만 꼭짓점 신축 — 원 · 호 · 글은 걸치면 그대로(오토캐드는 중심 · 끝점 기준).
|
|
*/
|
|
function stretchEntity(entity: Entity, box: Box | null, dx: number, dy: number): Entity | null {
|
|
const inside = (point: Point) =>
|
|
!!box &&
|
|
point.x >= box.xmin &&
|
|
point.x <= box.xmax &&
|
|
point.y >= box.ymin &&
|
|
point.y <= box.ymax;
|
|
const shift = (point: Point) => (inside(point) ? new Point(point.x + dx, point.y + dy) : point);
|
|
if (!box || entity.isContainedInBox(box) || !entity.intersectsWithBox(box)) {
|
|
const copy = cloneStyled(entity);
|
|
copy.move(dx, dy);
|
|
return inPlaceOf(entity, copy);
|
|
}
|
|
if (entity instanceof LineEntity) {
|
|
const start = entity.getStartPoint();
|
|
const end = entity.getEndPoint();
|
|
if (!inside(start) && !inside(end)) return null;
|
|
return inPlaceOf(entity, new LineEntity(entity.layerId, shift(start), shift(end)));
|
|
}
|
|
if (entity instanceof PolyLineEntity) {
|
|
let changed = false;
|
|
const parts = entity.getEntities().map((part) => {
|
|
if (!(part instanceof LineEntity)) return cloneStyled(part);
|
|
const start = part.getStartPoint();
|
|
const end = part.getEndPoint();
|
|
if (inside(start) || inside(end)) changed = true;
|
|
return copyStyle(part, new LineEntity(part.layerId, shift(start), shift(end)));
|
|
});
|
|
return changed ? inPlaceOf(entity, new PolyLineEntity(entity.layerId, parts)) : null;
|
|
}
|
|
if (entity instanceof RectangleEntity) {
|
|
const corners = (entity.getShape() as Polygon).vertices;
|
|
if (!corners.some(inside)) return null;
|
|
return inPlaceOf(entity, new RectangleEntity(entity.layerId, new Polygon(corners.map(shift))));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 고르기 중 마지막 걸침 창 — 선택 도구가 두 번째 구석을 기다릴 때의 누름 */
|
|
const watchWindow = assign(({ event, self }: { event: StateEvent; self: AnyActorRef }) => {
|
|
const snapshot = self.getSnapshot().children[SELECT_CHILD]?.getSnapshot();
|
|
const start = snapshot?.context?.startPoint as Point | null | undefined;
|
|
if (snapshot?.value !== SelectState.WAITING_FOR_SECOND_SELECT_POINT || !start) return {};
|
|
return { window: [start, (event as MouseClickEvent).worldMouseLocation] as [Point, Point] };
|
|
});
|
|
|
|
/**
|
|
* STRETCH(337 라 · 설계 1-3) — 기록 「걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...」 → 「객체 선택:」 →
|
|
* MOVE 와 같은 기준점 [변위(D)] · 두 번째 점 · 변위 기억(STRETCH.DISP) · 걸침 창 안 꼭짓점만 옮김.
|
|
*/
|
|
export const stretchToolStateMachine = createDisplaceTool({
|
|
tool: Tool.STRETCH,
|
|
remember: 'STRETCH.DISP',
|
|
intro: '걸치기 윈도우 또는 걸치기 폴리곤만큼 신축할 객체 선택...',
|
|
watch: watchWindow,
|
|
change: (context, dx, dy) => {
|
|
const window = context.window;
|
|
const box = window
|
|
? new Box(
|
|
Math.min(window[0].x, window[1].x),
|
|
Math.min(window[0].y, window[1].y),
|
|
Math.max(window[0].x, window[1].x),
|
|
Math.max(window[0].y, window[1].y)
|
|
)
|
|
: null;
|
|
return context.originals.flatMap((entity) => stretchEntity(entity, box, dx, dy) ?? []);
|
|
},
|
|
});
|
|
|
|
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);
|
|
},
|
|
});
|