/** 대칭·정렬·간격띄우기·신축·길이조정 (조사표 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 = (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); }, });